diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml deleted file mode 100644 index 11be548..0000000 --- a/.gitea/workflows/ci.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: CI - -"on": - push: - branches: - - main - pull_request: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Run go test - run: go test ./... - - - name: Run go vet - run: go vet ./... diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 51d46b6..9d9bc5f 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -26,19 +26,13 @@ jobs: set -euo pipefail current_tag="${GITHUB_REF_NAME}" - previous_stable_tag="" + previous_tag="" - if previous_stable_tag="$( - git describe --tags --abbrev=0 \ - --exclude '*-rc*' \ - --exclude '*-beta*' \ - --exclude '*-alpha*' \ - "${current_tag}^" 2>/dev/null - )"; then - range="${previous_stable_tag}..${current_tag}" + if previous_tag="$(git describe --tags --abbrev=0 "${current_tag}^" 2>/dev/null)"; then + range="${previous_tag}..${current_tag}" { printf '## Changelog\n\n' - printf 'Changes since `%s`.\n\n' "${previous_stable_tag}" + printf 'Changes since `%s`.\n\n' "${previous_tag}" git log --reverse --pretty=format:'- %h %s' "${range}" printf '\n' } >CHANGELOG.md @@ -61,24 +55,16 @@ jobs: current_tag="${GITHUB_REF_NAME}" api_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases" release_by_tag_url="${api_url}/tags/${current_tag}" - prerelease=false - - case "${current_tag}" in - *-rc*|*-beta*|*-alpha*) - prerelease=true - ;; - esac json_escape() { sed ':a;N;$!ba;s/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g;s/\r//g;s/\n/\\n/g' } body="$(json_escape < CHANGELOG.md)" - payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \ + payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":false}' \ "${current_tag}" \ "${current_tag}" \ - "${body}" \ - "${prerelease}")" + "${body}")" http_code="$( curl --silent --show-error --output release.json --write-out '%{http_code}' \ diff --git a/AGENTS.md b/AGENTS.md index 9cceb6c..1caefab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,8 +32,6 @@ Nommer la branche de manière explicite, par exemple : - `issue-8-update-drivers` - `docs-readme-installation` -Quand une branche est créée pour répondre à une issue et que cette issue porte le label `enhancement`, nommer la branche au format `feat/`. - Éviter de développer directement sur `main` quand le changement mérite une PR ou une validation fonctionnelle. ## Pull Requests diff --git a/README.md b/README.md index e874d95..abf66b0 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,349 @@ # mcp-framework -`mcp-framework` est une bibliothèque Go et un petit CLI pour construire des -binaires MCP avec une base commune : CLI, configuration locale, secrets, -manifeste `mcp.toml`, diagnostic et auto-update. +Bibliothèque Go pour construire des binaires MCP avec : + +- résolution de profils CLI +- stockage JSON de configuration dans `os.UserConfigDir()` +- stockage de secrets dans le wallet natif selon l'OS +- lecture d'un manifeste `mcp.toml` à la racine du projet +- pipeline d'auto-update via endpoint de release configurable + +Le framework est volontairement petit. Il fournit des briques réutilisables, +pas une application MCP complète. ## Installation -Dans un projet Go : - ```bash go get gitea.lclr.dev/AI/mcp-framework ``` -Pour utiliser le CLI : +## Packages -```bash -go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest +- `cli` : helpers pour résoudre un profil, valider une URL et demander des valeurs à l'utilisateur. +- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`. +- `manifest` : lecture de `mcp.toml` à la racine du projet et conversion vers `update.ReleaseSource`. +- `secretstore` : lecture/écriture de secrets dans le wallet natif. +- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. + +## Utilisation type + +Le flux typique côté application est : + +1. Résoudre le profil actif avec `cli`. +2. Charger la config versionnée avec `config`. +3. Lire les secrets avec `secretstore`. +4. Charger `mcp.toml` avec `manifest`. +5. Exécuter l'auto-update avec `update` si nécessaire. + +## Frontières Du Framework + +Ce qui est mutualisé par le framework : + +- la résolution du profil actif via `cli` +- le stockage JSON versionné de la config via `config` +- l'accès au wallet natif et aux secrets via `secretstore` +- le chargement de `mcp.toml` via `manifest` +- l'exécution du flux d'auto-update via `update` + +Ce qui reste volontairement spécifique à l'application cliente : + +- l'arbre de commandes et les alias métier (`config`, `setup`, `mcp`, `update`) +- les prompts interactifs et la validation métier des champs saisis +- le découpage entre données de config et secrets, ainsi que le nommage des secrets +- l'assemblage runtime entre config, secrets, services métier et serveur MCP +- le mapping final des erreurs techniques vers des messages utilisateur adaptés + +Ces limites sont des choix explicites : le framework fournit des briques +réutilisables, mais ne force pas une couche de bootstrap unique ni un contrat +applicatif trop spécifique à une application donnée. + +## Manifeste `mcp.toml` + +Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire +courant puis remonte les répertoires parents jusqu'à trouver le fichier. + +Exemple minimal : + +```toml +[update] +source_name = "Gitea releases" +base_url = "https://gitea.example.com" +latest_release_url = "https://gitea.example.com/api/v1/repos/org/repo/releases/latest" +token_header = "Authorization" +token_env_names = ["GITEA_TOKEN"] ``` -## Créer un projet MCP +Champs supportés dans `[update]` : -```bash -mcp-framework scaffold init \ - --target ./my-mcp \ - --module example.com/my-mcp \ - --binary my-mcp \ - --profiles dev,prod +- `source_name` : nom humain de la source de release, utilisé dans certains messages d'erreur. +- `base_url` : base de la forge ou du service de release. +- `latest_release_url` : URL complète qui retourne la release la plus récente. +- `token_header` : header HTTP à utiliser pour l'authentification. +- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token. -cd my-mcp -go mod tidy -go run ./cmd/my-mcp help +Exemple de chargement : + +```go +file, path, err := manifest.LoadDefault(".") +if err != nil { + return err +} + +fmt.Printf("manifest loaded from %s\n", path) +source := file.Update.ReleaseSource() ``` -Le scaffold crée une arborescence prête à adapter : +## Config JSON -```text -cmd//main.go -internal/app/app.go -mcp.toml -install.sh -README.md +Le package `config` stocke une structure générique par profil dans un JSON privé +pour l'utilisateur courant. + +Exemple : + +```go +type Profile struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` +} + +store := config.NewStore[Profile]("my-mcp") + +cfg, path, err := store.LoadDefault() +if err != nil { + return err +} + +profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) +profile := cfg.Profiles[profileName] + +profile.BaseURL = "https://api.example.com" +cfg.CurrentProfile = profileName +cfg.Profiles[profileName] = profile + +_, err = store.SaveDefault(cfg) +if err != nil { + return err +} + +fmt.Printf("config saved to %s\n", path) ``` -## Générer la glue depuis `mcp.toml` +Notes : -Dans un projet qui possède un `mcp.toml` à la racine : +- le fichier est créé avec des permissions `0600` +- le répertoire parent est forcé en `0700` +- l'écriture est atomique via un fichier temporaire puis `rename` +- si le fichier n'existe pas, `Load` et `LoadDefault` retournent une config vide par défaut +- `NewStoreWithOptions` permet de définir une version cible, des migrations JSON (`from -> to`) et une validation explicite après chargement +- une config plus récente que la version supportée, ou sans chemin de migration complet, retourne une erreur explicite -```bash -mcp-framework generate +Exemple de store versionné : + +```go +store := config.NewStoreWithOptions[Profile]("my-mcp", config.Options[Profile]{ + Version: 2, + Migrations: map[int]config.Migration{ + 1: func(doc map[string]json.RawMessage) error { + return nil + }, + }, + Validator: func(cfg config.FileConfig[Profile]) []config.ValidationIssue { + if cfg.CurrentProfile == "" { + return []config.ValidationIssue{{ + Path: "current_profile", + Message: "must not be empty", + }} + } + return nil + }, +}) ``` -La commande génère un package `mcpgen/` avec un loader de manifeste embarqué, -des helpers de métadonnées, update, secret store, et des helpers de config si -`[[config.fields]]` est déclaré. +## Secrets -En CI : +Le package `secretstore` supporte plusieurs politiques de backend : -```bash -mcp-framework generate --check +- `auto` : comportement par défaut, utilise un backend keyring disponible et peut retomber sur l'environnement si `LookupEnv` est fourni +- `kwallet-only` : impose KWallet et retourne une erreur explicite si KWallet n'est pas disponible +- `keyring-any` : impose l'utilisation d'un backend keyring disponible +- `env-only` : lecture seule depuis les variables d'environnement + +Backends keyring typiques : + +- macOS : Keychain +- Linux : Secret Service ou KWallet selon l'environnement +- Windows : Credential Manager + +Exemple : + +```go +store, err := secretstore.Open(secretstore.Options{ + ServiceName: "my-mcp", + BackendPolicy: secretstore.BackendAuto, +}) +if err != nil { + return err +} + +if err := store.SetSecret("api-token", "My MCP API token", token); err != nil { + return err +} + +token, err = store.GetSecret("api-token") +switch { +case err == nil: + // secret found +case errors.Is(err, secretstore.ErrNotFound): + // first run +default: + return err +} ``` -## Utiliser les packages +Pour imposer KWallet sur Linux : -Les packages peuvent être utilisés séparément : +```go +store, err := secretstore.Open(secretstore.Options{ + ServiceName: "email-mcp", + BackendPolicy: secretstore.BackendKWalletOnly, +}) +``` -- `bootstrap` : CLI commune (`setup`, `login`, `mcp`, `config`, `update`, `version`). -- `cli` : résolution de profil, setup interactif, résolution `flag/env/config/secret`, doctor. -- `config` : stockage JSON versionné dans le répertoire de config utilisateur. -- `manifest` : lecture de `mcp.toml` et fallback embarqué. -- `secretstore` : keyring natif, environnement ou Bitwarden CLI. -- `update` : téléchargement et remplacement du binaire depuis une release. -- `scaffold` : génération d'un squelette de projet. -- `generate` : génération de code Go depuis `mcp.toml`. +Pour stocker un secret structuré en JSON : -## Documentation +```go +type Credentials struct { + Host string `json:"host"` + Username string `json:"username"` + Password string `json:"password"` +} -- [Vue d'ensemble](docs/README.md) -- [Installation et utilisation](docs/getting-started.md) -- [Packages](docs/packages.md) -- [Bootstrap CLI](docs/bootstrap-cli.md) -- [Manifeste `mcp.toml`](docs/manifest.md) -- [Génération depuis `mcp.toml`](docs/generate.md) -- [Scaffolding](docs/scaffolding.md) -- [Config JSON](docs/config.md) -- [Secrets](docs/secrets.md) -- [Helpers CLI](docs/cli-helpers.md) -- [Auto-update](docs/auto-update.md) -- [Exemple minimal](docs/minimal-example.md) -- [Limites](docs/limitations.md) +err = secretstore.SetJSON(store, "imap-credentials", "IMAP credentials", Credentials{ + Host: "imap.example.com", + Username: "alice", + Password: token, +}) +if err != nil { + return err +} + +creds, err := secretstore.GetJSON[Credentials](store, "imap-credentials") +if err != nil { + return err +} +``` + +En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`. +Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`. + +## Helpers CLI + +`cli` fournit des helpers simples pour les assistants interactifs : + +```go +profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) + +baseURL, err := cli.PromptLine(reader, os.Stdout, "Base URL", profile.BaseURL) +if err != nil { + return err +} +if err := cli.ValidateBaseURL(baseURL); err != nil { + return err +} + +token, err := cli.PromptSecret(os.Stdin, os.Stdout, "API token", hasStoredSecret, storedToken) +if err != nil { + return err +} +``` + +## Auto-Update + +Le package `update` ne déduit pas la forge ni l'authentification. +L'application cliente fournit l'URL de release, le header d'auth éventuel et, +si besoin, les variables d'environnement à consulter. + +Le format attendu pour la réponse `latest release` est actuellement : + +```json +{ + "tag_name": "v1.2.3", + "assets": { + "links": [ + { + "name": "my-mcp-linux-amd64", + "url": "https://example.com/downloads/my-mcp-linux-amd64" + } + ] + } +} +``` + +Exemple : + +```go +file, _, err := manifest.LoadDefault(".") +if err != nil { + return err +} + +err = update.Run(ctx, update.Options{ + CurrentVersion: version, + BinaryName: "my-mcp", + ReleaseSource: file.Update.ReleaseSource(), + Stdout: os.Stdout, +}) +if err != nil { + return err +} +``` + +Contraintes actuelles : + +- le `latest_release_url` doit être renseigné explicitement +- les assets supportés sont `darwin/amd64`, `darwin/arm64`, `linux/amd64` et `windows/amd64` +- le remplacement du binaire n'est pas supporté sur Windows +- le nom de l'asset est dérivé de `BinaryName`, `GOOS` et `GOARCH` + +## Exemple Minimal + +```go +type Profile struct { + BaseURL string `json:"base_url"` +} + +func run(ctx context.Context, flagProfile string) error { + cfgStore := config.NewStore[Profile]("my-mcp") + + cfg, _, err := cfgStore.LoadDefault() + if err != nil { + return err + } + + profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) + profile := cfg.Profiles[profileName] + + manifestFile, _, err := manifest.LoadDefault(".") + if err != nil { + return err + } + + err = update.Run(ctx, update.Options{ + CurrentVersion: version, + BinaryName: "my-mcp", + ReleaseSource: manifestFile.Update.ReleaseSource(), + }) + if err != nil { + return err + } + + _ = profile + return nil +} +``` + +## Limites Actuelles + +- le manifeste gère uniquement la section `[update]` +- le framework ne fournit pas d'interface unique de bootstrap applicatif +- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go deleted file mode 100644 index 13fdd57..0000000 --- a/bootstrap/bootstrap.go +++ /dev/null @@ -1,568 +0,0 @@ -package bootstrap - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "sort" - "strings" -) - -const ( - CommandSetup = "setup" - CommandLogin = "login" - CommandMCP = "mcp" - CommandConfig = "config" - CommandUpdate = "update" - CommandVersion = "version" - CommandDoctor = "doctor" - - bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG" - - ConfigSubcommandShow = "show" - ConfigSubcommandTest = "test" - ConfigSubcommandDelete = "delete" -) - -var ( - ErrBinaryNameRequired = errors.New("binary name is required") - ErrUnknownCommand = errors.New("unknown command") - ErrUnknownSubcommand = errors.New("unknown subcommand") - ErrCommandNotConfigured = errors.New("command not configured") - ErrVersionRequired = errors.New("version is required when no version hook is configured") - ErrSubcommandRequired = errors.New("subcommand is required") -) - -type Handler func(context.Context, Invocation) error - -type Hooks struct { - Setup Handler - Login Handler - MCP Handler - Config Handler - ConfigShow Handler - ConfigTest Handler - ConfigDelete Handler - Update Handler - Version Handler -} - -type Options struct { - BinaryName string - Description string - Version string - Aliases map[string][]string - AliasDescriptions map[string]string - EnableDoctorAlias bool - Args []string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - Hooks Hooks -} - -type Invocation struct { - Command string - Args []string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer -} - -type commandDef struct { - Name string - Description string - Handler func(Hooks) Handler -} - -var commands = []commandDef{ - { - Name: CommandSetup, - Description: "Initialiser ou mettre a jour la configuration locale.", - Handler: func(h Hooks) Handler { - return h.Setup - }, - }, - { - Name: CommandLogin, - Description: "Authentifier et deverrouiller Bitwarden pour persister BW_SESSION.", - Handler: func(h Hooks) Handler { - return h.Login - }, - }, - { - Name: CommandMCP, - Description: "Executer la logique MCP principale du binaire.", - Handler: func(h Hooks) Handler { - return h.MCP - }, - }, - { - Name: CommandConfig, - Description: "Inspecter ou modifier la configuration.", - Handler: func(h Hooks) Handler { - return h.Config - }, - }, - { - Name: CommandUpdate, - Description: "Declencher le flux d'auto-update.", - Handler: func(h Hooks) Handler { - return h.Update - }, - }, - { - Name: CommandVersion, - Description: "Afficher la version du binaire.", - Handler: func(h Hooks) Handler { - return h.Version - }, - }, -} - -func Run(ctx context.Context, opts Options) error { - normalized := normalize(opts) - - if strings.TrimSpace(normalized.BinaryName) == "" { - return ErrBinaryNameRequired - } - - resolvedArgs := expandAliases(normalized.Args, normalized.Aliases) - resolvedArgs, debugEnabled := extractGlobalDebugFlag(resolvedArgs) - if debugEnabled { - _ = os.Setenv(bitwardenDebugEnvName, "1") - } - - command, commandArgs, showHelp := parseArgs(resolvedArgs) - if showHelp { - return printHelp(normalized, command, commandArgs) - } - - if command == "" { - return printHelp(normalized, "") - } - - if command == CommandConfig { - return runConfigCommand(ctx, normalized, commandArgs) - } - - handler, known := resolveHandler(command, normalized.Hooks) - if !known { - return fmt.Errorf("%w: %s", ErrUnknownCommand, command) - } - - if handler == nil { - if command == CommandVersion { - if strings.TrimSpace(normalized.Version) == "" { - return ErrVersionRequired - } - _, err := fmt.Fprintln(normalized.Stdout, normalized.Version) - return err - } - return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command) - } - - return handler(ctx, Invocation{ - Command: command, - Args: commandArgs, - Stdin: normalized.Stdin, - Stdout: normalized.Stdout, - Stderr: normalized.Stderr, - }) -} - -func normalize(opts Options) Options { - if opts.Stdin == nil { - opts.Stdin = os.Stdin - } - if opts.Stdout == nil { - opts.Stdout = os.Stdout - } - if opts.Stderr == nil { - opts.Stderr = os.Stderr - } - if opts.Args == nil { - opts.Args = os.Args[1:] - } - opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias) - opts.AliasDescriptions = normalizeAliasDescriptions(opts.AliasDescriptions, opts.Aliases, opts.EnableDoctorAlias) - return opts -} - -func normalizeAliases(aliases map[string][]string, enableDoctorAlias bool) map[string][]string { - normalized := make(map[string][]string, len(aliases)+1) - for name, target := range aliases { - trimmedName := strings.TrimSpace(name) - trimmedTarget := trimArgs(target) - if trimmedName == "" || len(trimmedTarget) == 0 { - continue - } - normalized[trimmedName] = trimmedTarget - } - - if enableDoctorAlias { - if _, ok := normalized[CommandDoctor]; !ok { - normalized[CommandDoctor] = []string{CommandConfig, ConfigSubcommandTest} - } - } - - if len(normalized) == 0 { - return nil - } - return normalized -} - -func normalizeAliasDescriptions(descriptions map[string]string, aliases map[string][]string, enableDoctorAlias bool) map[string]string { - normalized := make(map[string]string, len(descriptions)+1) - for name, description := range descriptions { - trimmedName := strings.TrimSpace(name) - trimmedDescription := strings.TrimSpace(description) - if trimmedName == "" || trimmedDescription == "" { - continue - } - if _, ok := aliases[trimmedName]; !ok { - continue - } - normalized[trimmedName] = trimmedDescription - } - - if enableDoctorAlias { - if _, ok := aliases[CommandDoctor]; ok { - if _, defined := normalized[CommandDoctor]; !defined { - normalized[CommandDoctor] = "Diagnostiquer la configuration locale." - } - } - } - - if len(normalized) == 0 { - return nil - } - return normalized -} - -func parseArgs(args []string) (command string, commandArgs []string, showHelp bool) { - args = trimArgs(args) - if len(args) == 0 { - return "", nil, false - } - - first := strings.TrimSpace(args[0]) - switch first { - case "help", "-h", "--help": - if len(args) > 1 { - return strings.TrimSpace(args[1]), trimArgs(args[2:]), true - } - return "", nil, true - } - - command = first - commandArgs = trimArgs(args[1:]) - if len(commandArgs) > 0 { - last := strings.TrimSpace(commandArgs[len(commandArgs)-1]) - if last == "-h" || last == "--help" { - return command, commandArgs[:len(commandArgs)-1], true - } - } - - return command, commandArgs, false -} - -func extractGlobalDebugFlag(args []string) ([]string, bool) { - args = trimArgs(args) - if len(args) == 0 { - return nil, false - } - - filtered := make([]string, 0, len(args)) - debugEnabled := false - for _, arg := range args { - if strings.TrimSpace(arg) == "--debug" { - debugEnabled = true - continue - } - filtered = append(filtered, arg) - } - - return filtered, debugEnabled -} - -func expandAliases(args []string, aliases map[string][]string) []string { - args = trimArgs(args) - if len(args) == 0 || len(aliases) == 0 { - return args - } - - if args[0] == "help" && len(args) > 1 { - expanded, ok := resolveAlias(aliases, args[1]) - if !ok { - return args - } - - withHelp := make([]string, 0, 1+len(expanded)+len(args[2:])) - withHelp = append(withHelp, "help") - withHelp = append(withHelp, expanded...) - withHelp = append(withHelp, args[2:]...) - return withHelp - } - - expanded, ok := resolveAlias(aliases, args[0]) - if !ok { - return args - } - - withCommand := make([]string, 0, len(expanded)+len(args[1:])) - withCommand = append(withCommand, expanded...) - withCommand = append(withCommand, args[1:]...) - - if len(withCommand) == 0 { - return args - } - - last := strings.TrimSpace(withCommand[len(withCommand)-1]) - if last != "-h" && last != "--help" { - return withCommand - } - - helpArgs := make([]string, 0, len(withCommand)) - helpArgs = append(helpArgs, "help") - helpArgs = append(helpArgs, withCommand[:len(withCommand)-1]...) - return helpArgs -} - -func resolveAlias(aliases map[string][]string, command string) ([]string, bool) { - target, ok := aliases[strings.TrimSpace(command)] - if !ok { - return nil, false - } - - expanded := trimArgs(target) - if len(expanded) == 0 { - return nil, false - } - - return expanded, true -} - -func trimArgs(args []string) []string { - if len(args) == 0 { - return nil - } - - result := make([]string, 0, len(args)) - for _, arg := range args { - trimmed := strings.TrimSpace(arg) - if trimmed == "" { - continue - } - result = append(result, trimmed) - } - return result -} - -func runConfigCommand(ctx context.Context, opts Options, args []string) error { - if len(args) == 0 { - return fmt.Errorf( - "%w: %s requires one of: %s, %s, %s", - ErrSubcommandRequired, - CommandConfig, - ConfigSubcommandShow, - ConfigSubcommandTest, - ConfigSubcommandDelete, - ) - } - - subcommand := strings.TrimSpace(args[0]) - subcommandArgs := args[1:] - - handler, known := resolveConfigHandler(opts.Hooks, subcommand) - if !known { - if opts.Hooks.Config != nil { - return opts.Hooks.Config(ctx, Invocation{ - Command: CommandConfig, - Args: args, - Stdin: opts.Stdin, - Stdout: opts.Stdout, - Stderr: opts.Stderr, - }) - } - return fmt.Errorf("%w: %s %s", ErrUnknownSubcommand, CommandConfig, subcommand) - } - - if handler == nil { - if opts.Hooks.Config != nil { - return opts.Hooks.Config(ctx, Invocation{ - Command: fmt.Sprintf("%s %s", CommandConfig, subcommand), - Args: subcommandArgs, - Stdin: opts.Stdin, - Stdout: opts.Stdout, - Stderr: opts.Stderr, - }) - } - return fmt.Errorf("%w: %s %s", ErrCommandNotConfigured, CommandConfig, subcommand) - } - - return handler(ctx, Invocation{ - Command: fmt.Sprintf("%s %s", CommandConfig, subcommand), - Args: subcommandArgs, - Stdin: opts.Stdin, - Stdout: opts.Stdout, - Stderr: opts.Stderr, - }) -} - -func resolveConfigHandler(hooks Hooks, subcommand string) (Handler, bool) { - switch subcommand { - case ConfigSubcommandShow: - return hooks.ConfigShow, true - case ConfigSubcommandTest: - return hooks.ConfigTest, true - case ConfigSubcommandDelete: - return hooks.ConfigDelete, true - default: - return nil, false - } -} - -func resolveHandler(command string, hooks Hooks) (Handler, bool) { - for _, def := range commands { - if def.Name == command { - return def.Handler(hooks), true - } - } - return nil, false -} - -func printHelp(opts Options, command string, args ...[]string) error { - var commandArgs []string - if len(args) > 0 { - commandArgs = args[0] - } - - if command == "" { - return printGlobalHelp(opts) - } - - if command == CommandConfig { - return printConfigHelp(opts, commandArgs) - } - - for _, def := range commands { - if def.Name != command { - continue - } - _, err := fmt.Fprintf( - opts.Stdout, - "Usage:\n %s %s [args]\n\n%s\n", - opts.BinaryName, - def.Name, - def.Description, - ) - return err - } - - return fmt.Errorf("%w: %s", ErrUnknownCommand, command) -} - -func printConfigHelp(opts Options, args []string) error { - if len(args) == 0 { - _, err := fmt.Fprintf( - opts.Stdout, - "Usage:\n %s config [args]\n\nSubcommandes:\n %-7s Afficher la configuration résolue et la provenance des valeurs.\n %-7s Vérifier la configuration et la connectivité.\n %-7s Supprimer un profil local (optionnel).\n", - opts.BinaryName, - ConfigSubcommandShow, - ConfigSubcommandTest, - ConfigSubcommandDelete, - ) - return err - } - - switch args[0] { - case ConfigSubcommandShow: - _, err := fmt.Fprintf( - opts.Stdout, - "Usage:\n %s config %s [args]\n\nAfficher la configuration résolue et l'origine des valeurs.\n", - opts.BinaryName, - ConfigSubcommandShow, - ) - return err - case ConfigSubcommandTest: - _, err := fmt.Fprintf( - opts.Stdout, - "Usage:\n %s config %s [args]\n\nTester la configuration résolue et la connectivité associée.\n", - opts.BinaryName, - ConfigSubcommandTest, - ) - return err - case ConfigSubcommandDelete: - _, err := fmt.Fprintf( - opts.Stdout, - "Usage:\n %s config %s [args]\n\nSupprimer un profil local de configuration.\n", - opts.BinaryName, - ConfigSubcommandDelete, - ) - return err - default: - return fmt.Errorf("%w: %s %s", ErrUnknownSubcommand, CommandConfig, args[0]) - } -} - -func printGlobalHelp(opts Options) error { - if strings.TrimSpace(opts.Description) != "" { - if _, err := fmt.Fprintf(opts.Stdout, "%s\n\n", opts.Description); err != nil { - return err - } - } - - if _, err := fmt.Fprintf(opts.Stdout, "Usage:\n %s [args]\n\n", opts.BinaryName); err != nil { - return err - } - if _, err := fmt.Fprintln(opts.Stdout, "Commandes communes:"); err != nil { - return err - } - - for _, def := range commands { - if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", def.Name, def.Description); err != nil { - return err - } - } - - if len(opts.Aliases) > 0 { - if _, err := fmt.Fprintln(opts.Stdout, "\nAlias:"); err != nil { - return err - } - - names := make([]string, 0, len(opts.Aliases)) - for name := range opts.Aliases { - names = append(names, name) - } - sort.Strings(names) - - for _, name := range names { - target := strings.Join(opts.Aliases[name], " ") - description := aliasDescription(opts.AliasDescriptions, name, target) - if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", name, description); err != nil { - return err - } - } - } - - if _, err := fmt.Fprintln(opts.Stdout, "\nOptions globales:"); err != nil { - return err - } - if _, err := fmt.Fprintln(opts.Stdout, " --debug Active le debug des appels Bitwarden."); err != nil { - return err - } - - _, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help \n", opts.BinaryName) - return err -} - -func aliasDescription(descriptions map[string]string, name, target string) string { - description := strings.TrimSpace(descriptions[name]) - if description == "" { - return fmt.Sprintf("Alias de %q.", target) - } - return fmt.Sprintf("%s (alias de %q).", description, target) -} diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go deleted file mode 100644 index ca2c34a..0000000 --- a/bootstrap/bootstrap_test.go +++ /dev/null @@ -1,609 +0,0 @@ -package bootstrap - -import ( - "bytes" - "context" - "errors" - "os" - "slices" - "strings" - "testing" -) - -func TestRunRoutesSetupHook(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - var got Invocation - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Version: "v1.2.3", - Args: []string{"setup", "--profile", "prod"}, - Stdout: &stdout, - Stderr: &stderr, - Hooks: Hooks{ - Setup: func(_ context.Context, inv Invocation) error { - got = inv - return nil - }, - }, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - if got.Command != CommandSetup { - t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup) - } - wantArgs := []string{"--profile", "prod"} - if !slices.Equal(got.Args, wantArgs) { - t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) - } -} - -func TestRunRoutesLoginHook(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - var got Invocation - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Version: "v1.2.3", - Args: []string{"login", "--force"}, - Stdout: &stdout, - Stderr: &stderr, - Hooks: Hooks{ - Login: func(_ context.Context, inv Invocation) error { - got = inv - return nil - }, - }, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - if got.Command != CommandLogin { - t.Fatalf("invocation command = %q, want %q", got.Command, CommandLogin) - } - wantArgs := []string{"--force"} - if !slices.Equal(got.Args, wantArgs) { - t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) - } -} - -func TestRunReturnsUnknownCommand(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"boom"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if !errors.Is(err, ErrUnknownCommand) { - t.Fatalf("Run error = %v, want ErrUnknownCommand", err) - } -} - -func TestRunReturnsCommandNotConfigured(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"config"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if !errors.Is(err, ErrSubcommandRequired) { - t.Fatalf("Run error = %v, want ErrSubcommandRequired", err) - } - if !strings.Contains(err.Error(), "show, test, delete") { - t.Fatalf("Run error = %q, want mention of show, test, delete", err) - } -} - -func TestRunPrintsVersionByDefault(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Version: "v1.2.3", - Args: []string{"version"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - if stdout.String() != "v1.2.3\n" { - t.Fatalf("stdout = %q, want %q", stdout.String(), "v1.2.3\n") - } -} - -func TestRunVersionHookOverridesDefault(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"version"}, - Stdout: &stdout, - Stderr: &stderr, - Hooks: Hooks{ - Version: func(_ context.Context, inv Invocation) error { - _, err := inv.Stdout.Write([]byte("custom-version\n")) - return err - }, - }, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - if stdout.String() != "custom-version\n" { - t.Fatalf("stdout = %q, want %q", stdout.String(), "custom-version\n") - } -} - -func TestRunRequiresVersionWithoutVersionHook(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"version"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if !errors.Is(err, ErrVersionRequired) { - t.Fatalf("Run error = %v, want ErrVersionRequired", err) - } -} - -func TestRunPrintsGlobalHelp(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Description: "Binaire MCP de test.", - Args: []string{"help"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - text := stdout.String() - for _, snippet := range []string{ - "Usage:", - "setup", - "mcp", - "config", - "update", - "version", - } { - if !strings.Contains(text, snippet) { - t.Fatalf("help output missing %q: %s", snippet, text) - } - } -} - -func TestRunPrintsGlobalHelpWhenNoArgs(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Description: "Binaire MCP de test.", - Args: []string{}, - Stdout: &stdout, - Stderr: &stderr, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - text := stdout.String() - for _, snippet := range []string{ - "Usage:", - "setup", - "mcp", - "config", - "update", - "version", - } { - if !strings.Contains(text, snippet) { - t.Fatalf("help output missing %q: %s", snippet, text) - } - } -} - -func TestRunPrintsCommandHelp(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"help", "update"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - text := stdout.String() - if !strings.Contains(text, "my-mcp update [args]") { - t.Fatalf("command help output = %q", text) - } - if !strings.Contains(text, "auto-update") { - t.Fatalf("command help output missing update description: %q", text) - } -} - -func TestRunPrintsLoginCommandHelp(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"help", "login"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - text := stdout.String() - if !strings.Contains(text, "my-mcp login [args]") { - t.Fatalf("command help output = %q", text) - } - if !strings.Contains(strings.ToLower(text), "bitwarden") { - t.Fatalf("command help output missing bitwarden description: %q", text) - } -} - -func TestRunPrintsConfigHelp(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"help", "config"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - text := stdout.String() - for _, snippet := range []string{ - "my-mcp config ", - "show", - "test", - } { - if !strings.Contains(text, snippet) { - t.Fatalf("config help output missing %q: %q", snippet, text) - } - } -} - -func TestRunPrintsConfigSubcommandHelp(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"help", "config", "show"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - text := stdout.String() - if !strings.Contains(text, "my-mcp config show [args]") { - t.Fatalf("config subcommand help output = %q", text) - } -} - -func TestRunRoutesConfigShowHook(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - var got Invocation - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"config", "show", "--profile", "prod"}, - Stdout: &stdout, - Stderr: &stderr, - Hooks: Hooks{ - ConfigShow: func(_ context.Context, inv Invocation) error { - got = inv - return nil - }, - }, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - if got.Command != "config show" { - t.Fatalf("invocation command = %q, want %q", got.Command, "config show") - } - wantArgs := []string{"--profile", "prod"} - if !slices.Equal(got.Args, wantArgs) { - t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) - } -} - -func TestRunConfigShowReturnsCommandNotConfigured(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"config", "show"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if !errors.Is(err, ErrCommandNotConfigured) { - t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err) - } -} - -func TestRunConfigReturnsUnknownSubcommand(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"config", "sync"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if !errors.Is(err, ErrUnknownSubcommand) { - t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err) - } -} - -func TestRunRoutesAliasToConfigSubcommandHook(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - var got Invocation - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Aliases: map[string][]string{ - "doctor": {CommandConfig, ConfigSubcommandTest}, - }, - Args: []string{"doctor", "--profile", "work"}, - Stdout: &stdout, - Stderr: &stderr, - Hooks: Hooks{ - ConfigTest: func(_ context.Context, inv Invocation) error { - got = inv - return nil - }, - }, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - if got.Command != "config test" { - t.Fatalf("invocation command = %q, want %q", got.Command, "config test") - } - wantArgs := []string{"--profile", "work"} - if !slices.Equal(got.Args, wantArgs) { - t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) - } -} - -func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Aliases: map[string][]string{ - "doctor": {CommandConfig, ConfigSubcommandTest}, - }, - Args: []string{"help", "doctor"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - text := stdout.String() - if !strings.Contains(text, "my-mcp config test [args]") { - t.Fatalf("command help output = %q", text) - } -} - -func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Aliases: map[string][]string{ - "doctor": {CommandConfig, ConfigSubcommandTest}, - }, - Args: []string{"doctor", "--help"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - text := stdout.String() - if !strings.Contains(text, "my-mcp config test [args]") { - t.Fatalf("command help output = %q", text) - } -} - -func TestRunPrintsAliasesInGlobalHelp(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Aliases: map[string][]string{ - "doctor": {CommandConfig, ConfigSubcommandTest}, - }, - AliasDescriptions: map[string]string{ - "doctor": "Diagnostiquer la configuration locale.", - }, - Args: []string{"help"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - text := stdout.String() - if !strings.Contains(text, "Alias:") { - t.Fatalf("global help output missing alias section: %q", text) - } - if !strings.Contains(text, `doctor Diagnostiquer la configuration locale. (alias de "config test").`) { - t.Fatalf("global help output missing doctor alias details: %q", text) - } -} - -func TestRunRoutesDoctorAliasWhenEnabled(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - var got Invocation - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - EnableDoctorAlias: true, - Args: []string{"doctor", "--profile", "prod"}, - Stdout: &stdout, - Stderr: &stderr, - Hooks: Hooks{ - ConfigTest: func(_ context.Context, inv Invocation) error { - got = inv - return nil - }, - }, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - if got.Command != "config test" { - t.Fatalf("invocation command = %q, want %q", got.Command, "config test") - } - wantArgs := []string{"--profile", "prod"} - if !slices.Equal(got.Args, wantArgs) { - t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) - } -} - -func TestRunPrintsDoctorAliasInGlobalHelpWhenEnabled(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - EnableDoctorAlias: true, - Args: []string{"help"}, - Stdout: &stdout, - Stderr: &stderr, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - text := stdout.String() - if !strings.Contains(text, "Alias:") { - t.Fatalf("global help output missing alias section: %q", text) - } - if !strings.Contains(text, `doctor Diagnostiquer la configuration locale. (alias de "config test").`) { - t.Fatalf("global help output missing default doctor alias details: %q", text) - } -} - -func TestRunAcceptsGlobalDebugFlagAndRoutesCommand(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - var got Invocation - - t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "") - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"--debug", "setup", "--profile", "prod"}, - Stdout: &stdout, - Stderr: &stderr, - Hooks: Hooks{ - Setup: func(_ context.Context, inv Invocation) error { - got = inv - return nil - }, - }, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - if got.Command != CommandSetup { - t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup) - } - wantArgs := []string{"--profile", "prod"} - if !slices.Equal(got.Args, wantArgs) { - t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) - } - if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" { - t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1") - } -} - -func TestRunAcceptsGlobalDebugFlagAfterCommand(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - var got Invocation - - t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "") - - err := Run(context.Background(), Options{ - BinaryName: "my-mcp", - Args: []string{"setup", "--debug", "--profile", "prod"}, - Stdout: &stdout, - Stderr: &stderr, - Hooks: Hooks{ - Setup: func(_ context.Context, inv Invocation) error { - got = inv - return nil - }, - }, - }) - if err != nil { - t.Fatalf("Run error = %v", err) - } - - if got.Command != CommandSetup { - t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup) - } - wantArgs := []string{"--profile", "prod"} - if !slices.Equal(got.Args, wantArgs) { - t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) - } - if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" { - t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1") - } -} diff --git a/cli/doctor.go b/cli/doctor.go deleted file mode 100644 index 10a508b..0000000 --- a/cli/doctor.go +++ /dev/null @@ -1,546 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "strings" - - "gitea.lclr.dev/AI/mcp-framework/config" - "gitea.lclr.dev/AI/mcp-framework/manifest" - "gitea.lclr.dev/AI/mcp-framework/secretstore" -) - -type DoctorStatus string - -const ( - DoctorStatusOK DoctorStatus = "ok" - DoctorStatusWarn DoctorStatus = "warn" - DoctorStatusFail DoctorStatus = "fail" -) - -type DoctorResult struct { - Name string - Status DoctorStatus - Summary string - Detail string -} - -type DoctorCheck func(context.Context) DoctorResult - -type DoctorReport struct { - Results []DoctorResult -} - -type DoctorSummary struct { - OK int - Warn int - Fail int - Total int -} - -type DoctorSecret struct { - Name string - Label string -} - -type DoctorManifestValidator func(manifest.File, string) []string - -type DoctorOptions struct { - ConfigCheck DoctorCheck - SecretStoreCheck DoctorCheck - SecretBackendPolicy secretstore.BackendPolicy - RequiredSecrets []DoctorSecret - SecretStoreFactory func() (secretstore.Store, error) - ManifestDir string - ManifestValidator DoctorManifestValidator - ManifestCheck DoctorCheck - ConnectivityCheck DoctorCheck - BitwardenOptions BitwardenDoctorOptions - DisableAutoBitwardenCheck bool - ExtraChecks []DoctorCheck -} - -type BitwardenDoctorOptions struct { - Command string - Debug bool - Shell string - LookupEnv func(string) (string, bool) -} - -var checkBitwardenReady = secretstore.EnsureBitwardenReady - -func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport { - checks := make([]DoctorCheck, 0, 5+len(options.ExtraChecks)) - - if options.ConfigCheck != nil { - checks = append(checks, options.ConfigCheck) - } - if options.SecretStoreCheck != nil { - checks = append(checks, options.SecretStoreCheck) - } - if len(options.RequiredSecrets) > 0 && options.SecretStoreFactory != nil { - checks = append(checks, RequiredSecretsCheck(options.SecretStoreFactory, options.RequiredSecrets)) - } - if options.ManifestCheck != nil { - checks = append(checks, options.ManifestCheck) - } else { - checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator)) - } - if options.ConnectivityCheck != nil { - checks = append(checks, options.ConnectivityCheck) - } - if shouldAutoIncludeBitwardenCheck(options) { - checks = append(checks, BitwardenReadyCheck(options.BitwardenOptions)) - } - checks = append(checks, options.ExtraChecks...) - - results := make([]DoctorResult, 0, len(checks)) - for _, check := range checks { - if check == nil { - continue - } - results = append(results, normalizeDoctorResult(check(ctx))) - } - - return DoctorReport{Results: results} -} - -func (r DoctorReport) Summary() DoctorSummary { - summary := DoctorSummary{Total: len(r.Results)} - for _, result := range r.Results { - switch result.Status { - case DoctorStatusOK: - summary.OK++ - case DoctorStatusWarn: - summary.Warn++ - case DoctorStatusFail: - summary.Fail++ - default: - summary.Fail++ - } - } - return summary -} - -func (r DoctorReport) HasFailures() bool { - return r.Summary().Fail > 0 -} - -func RenderDoctorReport(w io.Writer, report DoctorReport) error { - for _, result := range report.Results { - if _, err := fmt.Fprintf(w, "[%s] %s: %s\n", doctorLabel(result.Status), result.Name, result.Summary); err != nil { - return err - } - if detail := strings.TrimSpace(result.Detail); detail != "" { - if _, err := fmt.Fprintf(w, " %s\n", detail); err != nil { - return err - } - } - } - - summary := report.Summary() - _, err := fmt.Fprintf( - w, - "Summary: %d ok, %d warning(s), %d failure(s), %d total\n", - summary.OK, - summary.Warn, - summary.Fail, - summary.Total, - ) - return err -} - -func NewConfigCheck[T any](store config.Store[T]) DoctorCheck { - return func(context.Context) DoctorResult { - path, err := store.ConfigPath() - if err != nil { - return DoctorResult{ - Name: "config", - Status: DoctorStatusFail, - Summary: "cannot resolve config path", - Detail: err.Error(), - } - } - - info, err := os.Stat(path) - switch { - case err == nil && info.IsDir(): - return DoctorResult{ - Name: "config", - Status: DoctorStatusFail, - Summary: "config path is a directory", - Detail: path, - } - case err == nil: - if _, err := store.Load(path); err != nil { - return DoctorResult{ - Name: "config", - Status: DoctorStatusFail, - Summary: "config file is unreadable or invalid", - Detail: err.Error(), - } - } - return DoctorResult{ - Name: "config", - Status: DoctorStatusOK, - Summary: "config file is readable", - Detail: path, - } - case errors.Is(err, os.ErrNotExist): - return DoctorResult{ - Name: "config", - Status: DoctorStatusWarn, - Summary: "config file is missing", - Detail: path, - } - default: - return DoctorResult{ - Name: "config", - Status: DoctorStatusFail, - Summary: "cannot access config file", - Detail: err.Error(), - } - } - } -} - -func SecretStoreAvailabilityCheck(factory func() (secretstore.Store, error)) DoctorCheck { - return func(context.Context) DoctorResult { - if factory == nil { - return DoctorResult{ - Name: "secret-store", - Status: DoctorStatusWarn, - Summary: "secret store check is not configured", - } - } - - _, err := factory() - if err != nil { - return DoctorResult{ - Name: "secret-store", - Status: DoctorStatusFail, - Summary: "secret backend is unavailable", - Detail: err.Error(), - } - } - - return DoctorResult{ - Name: "secret-store", - Status: DoctorStatusOK, - Summary: "secret backend is available", - } - } -} - -func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck { - return func(context.Context) DoctorResult { - err := checkBitwardenReady(secretstore.Options{ - BitwardenCommand: strings.TrimSpace(options.Command), - BitwardenDebug: options.Debug, - Shell: strings.TrimSpace(options.Shell), - LookupEnv: options.LookupEnv, - }) - if err == nil { - return DoctorResult{ - Name: "bitwarden", - Status: DoctorStatusOK, - Summary: "bitwarden CLI is ready", - } - } - - switch { - case errors.Is(err, secretstore.ErrBWNotLoggedIn): - return DoctorResult{ - Name: "bitwarden", - Status: DoctorStatusFail, - Summary: "bitwarden login is required", - Detail: err.Error(), - } - case errors.Is(err, secretstore.ErrBWLocked): - return DoctorResult{ - Name: "bitwarden", - Status: DoctorStatusFail, - Summary: "bitwarden vault is locked or BW_SESSION is missing", - Detail: err.Error(), - } - case errors.Is(err, secretstore.ErrBWUnavailable): - return DoctorResult{ - Name: "bitwarden", - Status: DoctorStatusFail, - Summary: "bitwarden CLI is unavailable", - Detail: err.Error(), - } - default: - return DoctorResult{ - Name: "bitwarden", - Status: DoctorStatusFail, - Summary: "bitwarden readiness check failed", - Detail: err.Error(), - } - } - } -} - -func shouldAutoIncludeBitwardenCheck(options DoctorOptions) bool { - if options.DisableAutoBitwardenCheck { - return false - } - - if options.SecretBackendPolicy == secretstore.BackendBitwardenCLI { - return true - } - - if options.SecretStoreFactory == nil { - return false - } - - store, err := options.SecretStoreFactory() - if err == nil { - return secretstore.EffectiveBackendPolicy(store) == secretstore.BackendBitwardenCLI - } - - if errors.Is(err, secretstore.ErrBWNotLoggedIn) || - errors.Is(err, secretstore.ErrBWLocked) || - errors.Is(err, secretstore.ErrBWUnavailable) { - return true - } - - return strings.Contains(strings.ToLower(strings.TrimSpace(err.Error())), "bitwarden") -} - -func RequiredSecretsCheck(factory func() (secretstore.Store, error), required []DoctorSecret) DoctorCheck { - return func(context.Context) DoctorResult { - store, err := factory() - if err != nil { - return DoctorResult{ - Name: "required-secrets", - Status: DoctorStatusFail, - Summary: "cannot inspect required secrets", - Detail: err.Error(), - } - } - - var missing []string - for _, secret := range required { - name := strings.TrimSpace(secret.Name) - if name == "" { - continue - } - - _, err := store.GetSecret(name) - switch { - case err == nil: - case errors.Is(err, secretstore.ErrNotFound): - if label := strings.TrimSpace(secret.Label); label != "" { - missing = append(missing, fmt.Sprintf("%s (%s)", name, label)) - } else { - missing = append(missing, name) - } - default: - return DoctorResult{ - Name: "required-secrets", - Status: DoctorStatusFail, - Summary: fmt.Sprintf("cannot read secret %q", name), - Detail: err.Error(), - } - } - } - - if len(missing) > 0 { - return DoctorResult{ - Name: "required-secrets", - Status: DoctorStatusFail, - Summary: "required secrets are missing", - Detail: strings.Join(missing, ", "), - } - } - - return DoctorResult{ - Name: "required-secrets", - Status: DoctorStatusOK, - Summary: "all required secrets are present", - } - } -} - -func RequiredResolvedFieldsCheck(options ResolveOptions) DoctorCheck { - required := requiredFieldNames(options.Fields) - - return func(context.Context) DoctorResult { - if len(required) == 0 { - return DoctorResult{ - Name: "required-profile-fields", - Status: DoctorStatusWarn, - Summary: "no required profile field is configured", - } - } - - resolution, err := ResolveFields(options) - if err != nil { - return DoctorResult{ - Name: "required-profile-fields", - Status: DoctorStatusFail, - Summary: resolveFieldsErrorSummary(err), - Detail: FormatResolveFieldsError(err), - } - } - - sources := make([]string, 0, len(required)) - for _, name := range required { - field, ok := resolution.Get(name) - if !ok || !field.Found { - return DoctorResult{ - Name: "required-profile-fields", - Status: DoctorStatusFail, - Summary: "required profile values are missing", - Detail: name, - } - } - sources = append(sources, fmt.Sprintf("%s=%s", name, field.Source)) - } - - return DoctorResult{ - Name: "required-profile-fields", - Status: DoctorStatusOK, - Summary: "required profile values are resolved", - Detail: strings.Join(sources, ", "), - } - } -} - -func FormatResolveFieldsError(err error) string { - if err == nil { - return "" - } - - var missing *MissingRequiredValuesError - if errors.As(err, &missing) { - if len(missing.Fields) == 0 { - return "missing required configuration values" - } - return strings.Join(missing.Fields, ", ") - } - - var lookupErr *SourceLookupError - if errors.As(err, &lookupErr) { - key := strings.TrimSpace(lookupErr.Key) - if key == "" { - key = lookupErr.Field - } - return fmt.Sprintf( - "field %q via source %q (key %q): %v", - lookupErr.Field, - lookupErr.Source, - key, - lookupErr.Err, - ) - } - - return err.Error() -} - -func ManifestCheck(startDir string, validator DoctorManifestValidator) DoctorCheck { - return func(context.Context) DoctorResult { - file, path, err := manifest.LoadDefault(startDir) - if err != nil { - return DoctorResult{ - Name: "manifest", - Status: DoctorStatusFail, - Summary: "manifest is missing or invalid", - Detail: err.Error(), - } - } - - if validator != nil { - issues := validator(file, path) - if len(issues) > 0 { - return DoctorResult{ - Name: "manifest", - Status: DoctorStatusFail, - Summary: "manifest validation failed", - Detail: strings.Join(issues, "; "), - } - } - } - - return DoctorResult{ - Name: "manifest", - Status: DoctorStatusOK, - Summary: "manifest is valid", - Detail: path, - } - } -} - -func normalizeDoctorResult(result DoctorResult) DoctorResult { - result.Name = strings.TrimSpace(result.Name) - if result.Name == "" { - result.Name = "unnamed-check" - } - - result.Summary = strings.TrimSpace(result.Summary) - if result.Summary == "" { - result.Summary = "no details provided" - } - - result.Detail = strings.TrimSpace(result.Detail) - switch result.Status { - case DoctorStatusOK, DoctorStatusWarn, DoctorStatusFail: - default: - result.Status = DoctorStatusFail - } - - return result -} - -func doctorLabel(status DoctorStatus) string { - switch status { - case DoctorStatusOK: - return "OK" - case DoctorStatusWarn: - return "WARN" - default: - return "FAIL" - } -} - -func requiredFieldNames(specs []FieldSpec) []string { - required := make([]string, 0, len(specs)) - seen := make(map[string]struct{}, len(specs)) - - for _, spec := range specs { - if !spec.Required { - continue - } - - name := strings.TrimSpace(spec.Name) - if name == "" { - continue - } - if _, ok := seen[name]; ok { - continue - } - - seen[name] = struct{}{} - required = append(required, name) - } - - return required -} - -func resolveFieldsErrorSummary(err error) string { - var missing *MissingRequiredValuesError - if errors.As(err, &missing) { - return "required profile values are missing" - } - - var lookupErr *SourceLookupError - if errors.As(err, &lookupErr) { - return fmt.Sprintf("cannot resolve profile value %q", lookupErr.Field) - } - - return "profile resolution failed" -} diff --git a/cli/doctor_test.go b/cli/doctor_test.go deleted file mode 100644 index bbdc15e..0000000 --- a/cli/doctor_test.go +++ /dev/null @@ -1,448 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - "gitea.lclr.dev/AI/mcp-framework/config" - "gitea.lclr.dev/AI/mcp-framework/manifest" - "gitea.lclr.dev/AI/mcp-framework/secretstore" -) - -type doctorProfile struct { - BaseURL string `json:"base_url"` -} - -func TestRunDoctorAggregatesCommonChecksAndExtras(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tempHome) - t.Setenv("HOME", tempHome) - - store := config.NewStore[doctorProfile]("doctor-test") - path, err := store.ConfigPath() - if err != nil { - t.Fatalf("ConfigPath returned error: %v", err) - } - - cfg := config.FileConfig[doctorProfile]{ - Version: config.CurrentVersion, - CurrentProfile: "default", - Profiles: map[string]doctorProfile{ - "default": {BaseURL: "https://api.example.com"}, - }, - } - if err := store.Save(path, cfg); err != nil { - t.Fatalf("Save returned error: %v", err) - } - - manifestDir := t.TempDir() - if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - - secretFactory := func() (secretstore.Store, error) { - return secretstore.Open(secretstore.Options{ - BackendPolicy: secretstore.BackendEnvOnly, - LookupEnv: func(name string) (string, bool) { - if name == "api-token" { - return "secret", true - } - return "", false - }, - }) - } - - report := RunDoctor(context.Background(), DoctorOptions{ - ConfigCheck: NewConfigCheck(store), - SecretStoreCheck: SecretStoreAvailabilityCheck(secretFactory), - RequiredSecrets: []DoctorSecret{ - {Name: "api-token", Label: "API token"}, - }, - SecretStoreFactory: secretFactory, - ManifestDir: manifestDir, - ConnectivityCheck: func(context.Context) DoctorResult { - return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "service is reachable"} - }, - ExtraChecks: []DoctorCheck{ - func(context.Context) DoctorResult { - return DoctorResult{Name: "custom", Status: DoctorStatusWarn, Summary: "non blocking drift"} - }, - }, - }) - - summary := report.Summary() - if summary.OK != 5 || summary.Warn != 1 || summary.Fail != 0 || summary.Total != 6 { - t.Fatalf("summary = %#v", summary) - } -} - -func TestNewConfigCheckWarnsWhenConfigIsMissing(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tempHome) - t.Setenv("HOME", tempHome) - - store := config.NewStore[doctorProfile]("doctor-test") - result := NewConfigCheck(store)(context.Background()) - - if result.Status != DoctorStatusWarn { - t.Fatalf("status = %q, want warn", result.Status) - } - if !strings.Contains(result.Detail, "config.json") { - t.Fatalf("detail = %q, want config path", result.Detail) - } -} - -func TestRequiredSecretsCheckFailsWhenSecretIsMissing(t *testing.T) { - check := RequiredSecretsCheck(func() (secretstore.Store, error) { - return secretstore.Open(secretstore.Options{ - BackendPolicy: secretstore.BackendEnvOnly, - LookupEnv: func(name string) (string, bool) { - return "", false - }, - }) - }, []DoctorSecret{{Name: "API_TOKEN", Label: "API token"}}) - - result := check(context.Background()) - if result.Status != DoctorStatusFail { - t.Fatalf("status = %q, want fail", result.Status) - } - if !strings.Contains(result.Detail, "API_TOKEN") { - t.Fatalf("detail = %q, want secret name", result.Detail) - } -} - -func TestManifestCheckUsesValidator(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "mcp.toml"), []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - - result := ManifestCheck(dir, func(_ manifest.File, path string) []string { - _ = path - return []string{"missing business section"} - })(context.Background()) - - if result.Status != DoctorStatusFail { - t.Fatalf("status = %q, want fail", result.Status) - } -} - -func TestRunDoctorUsesCustomManifestCheckWhenProvided(t *testing.T) { - report := RunDoctor(context.Background(), DoctorOptions{ - ManifestDir: t.TempDir(), - ManifestCheck: func(context.Context) DoctorResult { - return DoctorResult{ - Name: "manifest", - Status: DoctorStatusOK, - Summary: "manifest is embedded", - Detail: "embedded:mcp.toml", - } - }, - }) - - if len(report.Results) != 1 { - t.Fatalf("result count = %d, want 1", len(report.Results)) - } - result := report.Results[0] - if result.Summary != "manifest is embedded" { - t.Fatalf("summary = %q", result.Summary) - } - if result.Detail != "embedded:mcp.toml" { - t.Fatalf("detail = %q", result.Detail) - } -} - -func TestRenderDoctorReportFormatsStatusesAndSummary(t *testing.T) { - var out bytes.Buffer - report := DoctorReport{ - Results: []DoctorResult{ - {Name: "config", Status: DoctorStatusOK, Summary: "config file is readable", Detail: "/tmp/config.json"}, - {Name: "custom", Status: DoctorStatusWarn, Summary: "optional check skipped"}, - {Name: "manifest", Status: DoctorStatusFail, Summary: "manifest is invalid", Detail: "parse error"}, - }, - } - - if err := RenderDoctorReport(&out, report); err != nil { - t.Fatalf("RenderDoctorReport returned error: %v", err) - } - - text := out.String() - for _, needle := range []string{ - "[OK] config: config file is readable", - "[WARN] custom: optional check skipped", - "[FAIL] manifest: manifest is invalid", - "Summary: 1 ok, 1 warning(s), 1 failure(s), 3 total", - } { - if !strings.Contains(text, needle) { - t.Fatalf("output = %q, want substring %q", text, needle) - } - } -} - -func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) { - result := SecretStoreAvailabilityCheck(func() (secretstore.Store, error) { - return nil, errors.New("backend missing") - })(context.Background()) - - if result.Status != DoctorStatusFail { - t.Fatalf("status = %q, want fail", result.Status) - } - if !strings.Contains(result.Detail, "backend missing") { - t.Fatalf("detail = %q", result.Detail) - } -} - -func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) { - prev := checkBitwardenReady - t.Cleanup(func() { - checkBitwardenReady = prev - }) - - t.Run("not logged in", func(t *testing.T) { - checkBitwardenReady = func(options secretstore.Options) error { - return fmt.Errorf("%w: run `bw login`", secretstore.ErrBWNotLoggedIn) - } - - result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background()) - if result.Status != DoctorStatusFail { - t.Fatalf("status = %q, want fail", result.Status) - } - if result.Summary != "bitwarden login is required" { - t.Fatalf("summary = %q", result.Summary) - } - if !strings.Contains(result.Detail, "bw login") { - t.Fatalf("detail = %q, want login remediation", result.Detail) - } - }) - - t.Run("locked", func(t *testing.T) { - checkBitwardenReady = func(options secretstore.Options) error { - return fmt.Errorf("%w: run `bw unlock --raw`", secretstore.ErrBWLocked) - } - - result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background()) - if result.Status != DoctorStatusFail { - t.Fatalf("status = %q, want fail", result.Status) - } - if result.Summary != "bitwarden vault is locked or BW_SESSION is missing" { - t.Fatalf("summary = %q", result.Summary) - } - if !strings.Contains(result.Detail, "bw unlock --raw") { - t.Fatalf("detail = %q, want unlock remediation", result.Detail) - } - }) - - t.Run("ready", func(t *testing.T) { - checkBitwardenReady = func(options secretstore.Options) error { - return nil - } - - result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background()) - if result.Status != DoctorStatusOK { - t.Fatalf("status = %q, want ok", result.Status) - } - if result.Summary != "bitwarden CLI is ready" { - t.Fatalf("summary = %q", result.Summary) - } - }) -} - -func TestRunDoctorAutoInjectsBitwardenCheckForBitwardenPolicy(t *testing.T) { - prev := checkBitwardenReady - t.Cleanup(func() { - checkBitwardenReady = prev - }) - - lookupCalled := false - checkBitwardenReady = func(options secretstore.Options) error { - if options.Shell != "fish" { - t.Fatalf("Shell = %q, want fish", options.Shell) - } - if options.BitwardenCommand != "bw" { - t.Fatalf("BitwardenCommand = %q, want bw", options.BitwardenCommand) - } - if options.LookupEnv == nil { - t.Fatal("LookupEnv should be forwarded") - } - _, _ = options.LookupEnv("BW_SESSION") - return fmt.Errorf("%w: run unlock", secretstore.ErrBWLocked) - } - - report := RunDoctor(context.Background(), DoctorOptions{ - SecretBackendPolicy: secretstore.BackendBitwardenCLI, - BitwardenOptions: BitwardenDoctorOptions{ - Command: "bw", - Shell: "fish", - LookupEnv: func(name string) (string, bool) { - lookupCalled = true - return "", false - }, - }, - ManifestCheck: func(context.Context) DoctorResult { - return DoctorResult{Name: "manifest", Status: DoctorStatusOK, Summary: "manifest ok"} - }, - }) - - if !lookupCalled { - t.Fatal("LookupEnv should be used by auto bitwarden check") - } - - var found *DoctorResult - for i := range report.Results { - if report.Results[i].Name == "bitwarden" { - found = &report.Results[i] - break - } - } - if found == nil { - t.Fatalf("report results = %#v, want auto bitwarden check", report.Results) - } - if found.Status != DoctorStatusFail { - t.Fatalf("bitwarden status = %q, want fail", found.Status) - } -} - -func TestRunDoctorCanDisableAutoBitwardenCheck(t *testing.T) { - prev := checkBitwardenReady - t.Cleanup(func() { - checkBitwardenReady = prev - }) - - checkBitwardenReady = func(options secretstore.Options) error { - t.Fatal("checkBitwardenReady should not be called when auto check is disabled") - return nil - } - - report := RunDoctor(context.Background(), DoctorOptions{ - DisableAutoBitwardenCheck: true, - SecretBackendPolicy: secretstore.BackendBitwardenCLI, - ManifestCheck: func(context.Context) DoctorResult { - return DoctorResult{Name: "manifest", Status: DoctorStatusOK, Summary: "manifest ok"} - }, - }) - - if len(report.Results) != 1 { - t.Fatalf("result count = %d, want 1", len(report.Results)) - } - if report.Results[0].Name != "manifest" { - t.Fatalf("result name = %q, want manifest", report.Results[0].Name) - } -} - -func TestRequiredResolvedFieldsCheckReportsSources(t *testing.T) { - check := RequiredResolvedFieldsCheck(ResolveOptions{ - Fields: []FieldSpec{ - {Name: "base_url", Required: true, ConfigKey: "base_url"}, - { - Name: "api_token", - Required: true, - EnvKey: "MY_API_TOKEN", - SecretKey: "my-api-token", - Sources: []ValueSource{SourceEnv, SourceSecret}, - }, - }, - Lookup: ResolveLookup(ResolveLookupOptions{ - Env: MapLookup(map[string]string{"MY_API_TOKEN": "env-token"}), - Config: ConfigMap(map[string]string{"base_url": "https://api.example.com"}), - Secret: MapLookup(map[string]string{"my-api-token": "secret-token"}), - }), - }) - - result := check(context.Background()) - if result.Status != DoctorStatusOK { - t.Fatalf("status = %q, want ok", result.Status) - } - for _, needle := range []string{"base_url=config", "api_token=env"} { - if !strings.Contains(result.Detail, needle) { - t.Fatalf("detail = %q, want substring %q", result.Detail, needle) - } - } -} - -func TestRequiredResolvedFieldsCheckUsesSecretAsFallback(t *testing.T) { - check := RequiredResolvedFieldsCheck(ResolveOptions{ - Fields: []FieldSpec{ - { - Name: "api_token", - Required: true, - EnvKey: "MY_API_TOKEN", - SecretKey: "my-api-token", - Sources: []ValueSource{SourceEnv, SourceSecret}, - }, - }, - Lookup: ResolveLookup(ResolveLookupOptions{ - Env: MapLookup(map[string]string{}), - Secret: MapLookup(map[string]string{"my-api-token": "secret-token"}), - }), - }) - - result := check(context.Background()) - if result.Status != DoctorStatusOK { - t.Fatalf("status = %q, want ok", result.Status) - } - if !strings.Contains(result.Detail, "api_token=secret") { - t.Fatalf("detail = %q, want secret provenance", result.Detail) - } -} - -func TestRequiredResolvedFieldsCheckFailsWithMissingRequiredField(t *testing.T) { - check := RequiredResolvedFieldsCheck(ResolveOptions{ - Fields: []FieldSpec{ - {Name: "base_url", Required: true}, - }, - Lookup: ResolveLookup(ResolveLookupOptions{ - Env: MapLookup(map[string]string{}), - }), - }) - - result := check(context.Background()) - if result.Status != DoctorStatusFail { - t.Fatalf("status = %q, want fail", result.Status) - } - if result.Summary != "required profile values are missing" { - t.Fatalf("summary = %q", result.Summary) - } - if result.Detail != "base_url" { - t.Fatalf("detail = %q, want base_url", result.Detail) - } -} - -func TestRequiredResolvedFieldsCheckFormatsLookupErrors(t *testing.T) { - lookupErr := errors.New("vault unavailable") - check := RequiredResolvedFieldsCheck(ResolveOptions{ - Fields: []FieldSpec{ - {Name: "api_token", Required: true, Sources: []ValueSource{SourceSecret}}, - }, - Lookup: func(source ValueSource, key string) (string, bool, error) { - if source == SourceSecret { - return "", false, lookupErr - } - return "", false, nil - }, - }) - - result := check(context.Background()) - if result.Status != DoctorStatusFail { - t.Fatalf("status = %q, want fail", result.Status) - } - if result.Summary != "cannot resolve profile value \"api_token\"" { - t.Fatalf("summary = %q", result.Summary) - } - for _, needle := range []string{"api_token", "source \"secret\"", "key \"api_token\"", "vault unavailable"} { - if !strings.Contains(result.Detail, needle) { - t.Fatalf("detail = %q, want substring %q", result.Detail, needle) - } - } -} - -func TestFormatResolveFieldsErrorWithNilError(t *testing.T) { - if got := FormatResolveFieldsError(nil); got != "" { - t.Fatalf("FormatResolveFieldsError(nil) = %q, want empty string", got) - } -} diff --git a/cli/resolve.go b/cli/resolve.go deleted file mode 100644 index 725571b..0000000 --- a/cli/resolve.go +++ /dev/null @@ -1,283 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "io" - "slices" - "strings" -) - -type ValueSource string - -const ( - SourceFlag ValueSource = "flag" - SourceEnv ValueSource = "env" - SourceConfig ValueSource = "config" - SourceSecret ValueSource = "secret" - SourceDefault ValueSource = "default" -) - -var DefaultResolutionOrder = []ValueSource{ - SourceFlag, - SourceEnv, - SourceConfig, - SourceSecret, -} - -var ErrInvalidResolverInput = errors.New("invalid resolver input") - -type FieldSpec struct { - Name string - Required bool - DefaultValue string - Sources []ValueSource - FlagKey string - EnvKey string - ConfigKey string - SecretKey string -} - -type LookupFunc func(source ValueSource, key string) (string, bool, error) - -type ResolveOptions struct { - Fields []FieldSpec - Order []ValueSource - Lookup LookupFunc -} - -type ResolvedField struct { - Name string - Value string - Source ValueSource - Found bool -} - -type Resolution struct { - Fields []ResolvedField -} - -func (r Resolution) Get(name string) (ResolvedField, bool) { - needle := strings.TrimSpace(name) - for _, field := range r.Fields { - if field.Name == needle { - return field, true - } - } - return ResolvedField{}, false -} - -type MissingRequiredValuesError struct { - Fields []string -} - -func (e *MissingRequiredValuesError) Error() string { - return fmt.Sprintf("missing required configuration values: %s", strings.Join(e.Fields, ", ")) -} - -type SourceLookupError struct { - Field string - Source ValueSource - Key string - Err error -} - -func (e *SourceLookupError) Error() string { - return fmt.Sprintf( - "resolve %q from %q (key %q): %v", - e.Field, - e.Source, - e.Key, - e.Err, - ) -} - -func (e *SourceLookupError) Unwrap() error { - return e.Err -} - -type StaticLookup struct { - Flags map[string]string - Env map[string]string - Config map[string]string - Secrets map[string]string -} - -func (l StaticLookup) Lookup(source ValueSource, key string) (string, bool, error) { - var values map[string]string - switch source { - case SourceFlag: - values = l.Flags - case SourceEnv: - values = l.Env - case SourceConfig: - values = l.Config - case SourceSecret: - values = l.Secrets - case SourceDefault: - return "", false, fmt.Errorf("%w: source %q is reserved", ErrInvalidResolverInput, source) - default: - return "", false, fmt.Errorf("%w: unknown source %q", ErrInvalidResolverInput, source) - } - - value, ok := values[key] - return value, ok, nil -} - -func ResolveFields(options ResolveOptions) (Resolution, error) { - if options.Lookup == nil { - return Resolution{}, fmt.Errorf("%w: lookup is required", ErrInvalidResolverInput) - } - - globalOrder, err := normalizeOrder(options.Order, DefaultResolutionOrder) - if err != nil { - return Resolution{}, fmt.Errorf("%w: %v", ErrInvalidResolverInput, err) - } - - resolution := Resolution{ - Fields: make([]ResolvedField, 0, len(options.Fields)), - } - missingRequired := make([]string, 0) - seenNames := make(map[string]struct{}, len(options.Fields)) - - for i, spec := range options.Fields { - name := strings.TrimSpace(spec.Name) - if name == "" { - return Resolution{}, fmt.Errorf("%w: field at index %d has empty name", ErrInvalidResolverInput, i) - } - if _, exists := seenNames[name]; exists { - return Resolution{}, fmt.Errorf("%w: duplicate field name %q", ErrInvalidResolverInput, name) - } - seenNames[name] = struct{}{} - - order := globalOrder - if len(spec.Sources) > 0 { - order, err = normalizeOrder(spec.Sources, globalOrder) - if err != nil { - return Resolution{}, fmt.Errorf("%w: field %q: %v", ErrInvalidResolverInput, name, err) - } - } - - field := ResolvedField{Name: name} - for _, source := range order { - key := spec.keyFor(source) - if key == "" { - continue - } - - value, found, err := options.Lookup(source, key) - if err != nil { - return resolution, &SourceLookupError{ - Field: name, - Source: source, - Key: key, - Err: err, - } - } - - trimmed := strings.TrimSpace(value) - if found && trimmed != "" { - field.Value = trimmed - field.Source = source - field.Found = true - break - } - } - - if !field.Found { - defaultValue := strings.TrimSpace(spec.DefaultValue) - if defaultValue != "" { - field.Value = defaultValue - field.Source = SourceDefault - field.Found = true - } - } - - if spec.Required && !field.Found { - missingRequired = append(missingRequired, name) - } - resolution.Fields = append(resolution.Fields, field) - } - - if len(missingRequired) > 0 { - return resolution, &MissingRequiredValuesError{Fields: missingRequired} - } - - return resolution, nil -} - -func RenderResolutionProvenance(w io.Writer, resolution Resolution) error { - for _, field := range resolution.Fields { - source := "missing" - if field.Found { - source = string(field.Source) - } - - if _, err := fmt.Fprintf(w, "%s: %s\n", field.Name, source); err != nil { - return err - } - } - - return nil -} - -func normalizeOrder(input []ValueSource, fallback []ValueSource) ([]ValueSource, error) { - order := input - if len(order) == 0 { - order = fallback - } - - result := make([]ValueSource, 0, len(order)) - seen := make(map[ValueSource]struct{}, len(order)) - for _, source := range order { - if !isKnownSource(source) { - return nil, fmt.Errorf("unknown source %q", source) - } - if source == SourceDefault { - return nil, fmt.Errorf("source %q cannot be used in resolution order", source) - } - if _, exists := seen[source]; exists { - continue - } - - seen[source] = struct{}{} - result = append(result, source) - } - - if len(result) == 0 { - return nil, fmt.Errorf("resolution order is empty") - } - - return slices.Clone(result), nil -} - -func isKnownSource(source ValueSource) bool { - switch source { - case SourceFlag, SourceEnv, SourceConfig, SourceSecret, SourceDefault: - return true - default: - return false - } -} - -func (s FieldSpec) keyFor(source ValueSource) string { - switch source { - case SourceFlag: - return fallbackKey(s.FlagKey, s.Name) - case SourceEnv: - return fallbackKey(s.EnvKey, s.Name) - case SourceConfig: - return fallbackKey(s.ConfigKey, s.Name) - case SourceSecret: - return fallbackKey(s.SecretKey, s.Name) - default: - return "" - } -} - -func fallbackKey(explicit, fallback string) string { - if key := strings.TrimSpace(explicit); key != "" { - return key - } - return strings.TrimSpace(fallback) -} diff --git a/cli/resolve_lookup.go b/cli/resolve_lookup.go deleted file mode 100644 index f42fc45..0000000 --- a/cli/resolve_lookup.go +++ /dev/null @@ -1,87 +0,0 @@ -package cli - -import ( - "errors" - "os" - - "gitea.lclr.dev/AI/mcp-framework/secretstore" -) - -type KeyLookupFunc func(key string) (string, bool, error) - -type ResolveLookupOptions struct { - Flag KeyLookupFunc - Env KeyLookupFunc - Config KeyLookupFunc - Secret KeyLookupFunc -} - -func ResolveLookup(options ResolveLookupOptions) LookupFunc { - return func(source ValueSource, key string) (string, bool, error) { - switch source { - case SourceFlag: - return runKeyLookup(options.Flag, key) - case SourceEnv: - return runKeyLookup(options.Env, key) - case SourceConfig: - return runKeyLookup(options.Config, key) - case SourceSecret: - return runKeyLookup(options.Secret, key) - case SourceDefault: - return "", false, nil - default: - return "", false, nil - } - } -} - -func runKeyLookup(lookup KeyLookupFunc, key string) (string, bool, error) { - if lookup == nil { - return "", false, nil - } - if key == "" { - return "", false, nil - } - return lookup(key) -} - -func MapLookup(values map[string]string) KeyLookupFunc { - return func(key string) (string, bool, error) { - value, ok := values[key] - return value, ok, nil - } -} - -func EnvLookup(lookup func(string) (string, bool)) KeyLookupFunc { - reader := lookup - if reader == nil { - reader = os.LookupEnv - } - - return func(key string) (string, bool, error) { - value, ok := reader(key) - return value, ok, nil - } -} - -func ConfigMap(values map[string]string) KeyLookupFunc { - return MapLookup(values) -} - -func SecretStore(store secretstore.Store) KeyLookupFunc { - if store == nil { - return nil - } - - return func(key string) (string, bool, error) { - value, err := store.GetSecret(key) - switch { - case err == nil: - return value, true, nil - case errors.Is(err, secretstore.ErrNotFound): - return "", false, nil - default: - return "", false, err - } - } -} diff --git a/cli/resolve_lookup_test.go b/cli/resolve_lookup_test.go deleted file mode 100644 index 32e4d23..0000000 --- a/cli/resolve_lookup_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package cli - -import ( - "errors" - "testing" - - "gitea.lclr.dev/AI/mcp-framework/secretstore" -) - -type testSecretStore struct { - values map[string]string - errs map[string]error -} - -func (s testSecretStore) SetSecret(name, label, secret string) error { - return nil -} - -func (s testSecretStore) GetSecret(name string) (string, error) { - if err, ok := s.errs[name]; ok { - return "", err - } - value, ok := s.values[name] - if !ok { - return "", secretstore.ErrNotFound - } - return value, nil -} - -func (s testSecretStore) DeleteSecret(name string) error { - return nil -} - -func TestResolveLookupWithStandardProviders(t *testing.T) { - t.Setenv("MCP_PASSWORD", "") - - lookup := ResolveLookup(ResolveLookupOptions{ - Flag: MapLookup(map[string]string{"host": "https://flag.example.com"}), - Env: EnvLookup(nil), - Config: ConfigMap(map[string]string{"username": "config-user"}), - Secret: SecretStore(testSecretStore{ - values: map[string]string{"smtp-password": "secret-password"}, - }), - }) - - resolution, err := ResolveFields(ResolveOptions{ - Fields: []FieldSpec{ - { - Name: "host", - Required: true, - FlagKey: "host", - }, - { - Name: "username", - Required: true, - EnvKey: "MCP_USERNAME", - ConfigKey: "username", - }, - { - Name: "password", - Required: true, - EnvKey: "MCP_PASSWORD", - SecretKey: "smtp-password", - }, - }, - Lookup: lookup, - }) - if err != nil { - t.Fatalf("ResolveFields returned error: %v", err) - } - - host, _ := resolution.Get("host") - if host.Source != SourceFlag || host.Value != "https://flag.example.com" { - t.Fatalf("host = %+v", host) - } - - username, _ := resolution.Get("username") - if username.Source != SourceConfig || username.Value != "config-user" { - t.Fatalf("username = %+v", username) - } - - password, _ := resolution.Get("password") - if password.Source != SourceSecret || password.Value != "secret-password" { - t.Fatalf("password = %+v", password) - } -} - -func TestSecretStoreProviderTreatsErrNotFoundAsMissing(t *testing.T) { - lookup := ResolveLookup(ResolveLookupOptions{ - Secret: SecretStore(testSecretStore{ - errs: map[string]error{"smtp-password": secretstore.ErrNotFound}, - }), - }) - - resolution, err := ResolveFields(ResolveOptions{ - Fields: []FieldSpec{ - { - Name: "password", - Required: true, - SecretKey: "smtp-password", - DefaultValue: "fallback-password", - }, - }, - Lookup: lookup, - }) - if err != nil { - t.Fatalf("ResolveFields returned error: %v", err) - } - - password, _ := resolution.Get("password") - if password.Source != SourceDefault || password.Value != "fallback-password" { - t.Fatalf("password = %+v", password) - } -} - -func TestSecretStoreProviderPropagatesBackendErrors(t *testing.T) { - backendErr := errors.New("backend unavailable") - lookup := ResolveLookup(ResolveLookupOptions{ - Secret: SecretStore(testSecretStore{ - errs: map[string]error{"smtp-password": backendErr}, - }), - }) - - _, err := ResolveFields(ResolveOptions{ - Fields: []FieldSpec{ - { - Name: "password", - Required: true, - SecretKey: "smtp-password", - }, - }, - Lookup: lookup, - }) - - var sourceErr *SourceLookupError - if !errors.As(err, &sourceErr) { - t.Fatalf("ResolveFields error = %v, want SourceLookupError", err) - } - if sourceErr.Source != SourceSecret { - t.Fatalf("sourceErr.Source = %q, want %q", sourceErr.Source, SourceSecret) - } - if !errors.Is(err, backendErr) { - t.Fatalf("ResolveFields error should wrap backend error") - } -} diff --git a/cli/resolve_test.go b/cli/resolve_test.go deleted file mode 100644 index 27792fe..0000000 --- a/cli/resolve_test.go +++ /dev/null @@ -1,291 +0,0 @@ -package cli - -import ( - "bytes" - "errors" - "strings" - "testing" -) - -func TestResolveFieldsUsesDefaultOrderAndTracksSource(t *testing.T) { - resolution, err := ResolveFields(ResolveOptions{ - Fields: []FieldSpec{ - { - Name: "base_url", - Required: true, - FlagKey: "base-url", - }, - { - Name: "api_token", - Required: true, - EnvKey: "API_TOKEN", - SecretKey: "my-api-token", - }, - { - Name: "stream_id", - }, - }, - Lookup: StaticLookup{ - Flags: map[string]string{ - "base-url": "https://flag.example.com", - }, - Env: map[string]string{ - "base_url": "https://env.example.com", - "API_TOKEN": "", - }, - Config: map[string]string{ - "stream_id": "stream-from-config", - }, - Secrets: map[string]string{ - "my-api-token": "secret-token", - }, - }.Lookup, - }) - if err != nil { - t.Fatalf("ResolveFields returned error: %v", err) - } - - baseURL, ok := resolution.Get("base_url") - if !ok { - t.Fatalf("base_url was not resolved") - } - if !baseURL.Found { - t.Fatalf("base_url must be found") - } - if baseURL.Source != SourceFlag { - t.Fatalf("base_url source = %q, want %q", baseURL.Source, SourceFlag) - } - if baseURL.Value != "https://flag.example.com" { - t.Fatalf("base_url value = %q", baseURL.Value) - } - - apiToken, ok := resolution.Get("api_token") - if !ok { - t.Fatalf("api_token was not resolved") - } - if apiToken.Source != SourceSecret { - t.Fatalf("api_token source = %q, want %q", apiToken.Source, SourceSecret) - } - if apiToken.Value != "secret-token" { - t.Fatalf("api_token value = %q", apiToken.Value) - } - - streamID, ok := resolution.Get("stream_id") - if !ok { - t.Fatalf("stream_id was not resolved") - } - if streamID.Source != SourceConfig { - t.Fatalf("stream_id source = %q, want %q", streamID.Source, SourceConfig) - } - if streamID.Value != "stream-from-config" { - t.Fatalf("stream_id value = %q", streamID.Value) - } -} - -func TestResolveFieldsSupportsCustomGlobalOrder(t *testing.T) { - resolution, err := ResolveFields(ResolveOptions{ - Fields: []FieldSpec{ - { - Name: "base_url", - Required: true, - }, - }, - Order: []ValueSource{SourceConfig, SourceEnv, SourceFlag}, - Lookup: StaticLookup{ - Flags: map[string]string{ - "base_url": "https://flag.example.com", - }, - Env: map[string]string{ - "base_url": "https://env.example.com", - }, - Config: map[string]string{ - "base_url": "https://config.example.com", - }, - }.Lookup, - }) - if err != nil { - t.Fatalf("ResolveFields returned error: %v", err) - } - - baseURL, _ := resolution.Get("base_url") - if baseURL.Source != SourceConfig { - t.Fatalf("base_url source = %q, want %q", baseURL.Source, SourceConfig) - } -} - -func TestResolveFieldsSupportsPerFieldOrderAndDefaultValues(t *testing.T) { - resolution, err := ResolveFields(ResolveOptions{ - Fields: []FieldSpec{ - { - Name: "api_token", - Required: true, - Sources: []ValueSource{SourceSecret, SourceEnv}, - SecretKey: "API_TOKEN_SECRET", - DefaultValue: "default-token", - }, - { - Name: "region", - DefaultValue: "eu-west", - }, - }, - Lookup: StaticLookup{ - Env: map[string]string{ - "api_token": "env-token", - }, - Secrets: map[string]string{ - "API_TOKEN_SECRET": "secret-token", - }, - }.Lookup, - }) - if err != nil { - t.Fatalf("ResolveFields returned error: %v", err) - } - - apiToken, _ := resolution.Get("api_token") - if apiToken.Source != SourceSecret { - t.Fatalf("api_token source = %q, want %q", apiToken.Source, SourceSecret) - } - if apiToken.Value != "secret-token" { - t.Fatalf("api_token value = %q", apiToken.Value) - } - - region, _ := resolution.Get("region") - if region.Source != SourceDefault { - t.Fatalf("region source = %q, want %q", region.Source, SourceDefault) - } - if region.Value != "eu-west" { - t.Fatalf("region value = %q, want eu-west", region.Value) - } -} - -func TestResolveFieldsReturnsHomogeneousMissingRequiredError(t *testing.T) { - resolution, err := ResolveFields(ResolveOptions{ - Fields: []FieldSpec{ - {Name: "base_url", Required: true}, - {Name: "api_token", Required: true}, - {Name: "region"}, - }, - Lookup: StaticLookup{}.Lookup, - }) - - var missingErr *MissingRequiredValuesError - if !errors.As(err, &missingErr) { - t.Fatalf("ResolveFields error = %v, want MissingRequiredValuesError", err) - } - - if got := strings.Join(missingErr.Fields, ","); got != "base_url,api_token" { - t.Fatalf("missing fields = %q, want base_url,api_token", got) - } - - region, ok := resolution.Get("region") - if !ok { - t.Fatalf("region should exist in partial resolution") - } - if region.Found { - t.Fatalf("region should not be found") - } -} - -func TestResolveFieldsWrapsLookupErrorsWithContext(t *testing.T) { - lookupErr := errors.New("secret backend unavailable") - _, err := ResolveFields(ResolveOptions{ - Fields: []FieldSpec{ - { - Name: "api_token", - Sources: []ValueSource{SourceSecret}, - }, - }, - Lookup: func(source ValueSource, key string) (string, bool, error) { - return "", false, lookupErr - }, - }) - - var sourceErr *SourceLookupError - if !errors.As(err, &sourceErr) { - t.Fatalf("ResolveFields error = %v, want SourceLookupError", err) - } - if sourceErr.Field != "api_token" { - t.Fatalf("sourceErr.Field = %q, want api_token", sourceErr.Field) - } - if sourceErr.Source != SourceSecret { - t.Fatalf("sourceErr.Source = %q, want %q", sourceErr.Source, SourceSecret) - } - if !errors.Is(err, lookupErr) { - t.Fatalf("ResolveFields error should wrap the original lookup error") - } -} - -func TestResolveFieldsRejectsInvalidDefinitions(t *testing.T) { - tests := []struct { - name string - options ResolveOptions - }{ - { - name: "missing lookup", - options: ResolveOptions{ - Fields: []FieldSpec{{Name: "base_url"}}, - }, - }, - { - name: "empty field name", - options: ResolveOptions{ - Fields: []FieldSpec{{Name: " "}}, - Lookup: StaticLookup{}.Lookup, - }, - }, - { - name: "duplicate field names", - options: ResolveOptions{ - Fields: []FieldSpec{{Name: "base_url"}, {Name: "base_url"}}, - Lookup: StaticLookup{}.Lookup, - }, - }, - { - name: "unknown source in order", - options: ResolveOptions{ - Fields: []FieldSpec{{Name: "base_url"}}, - Order: []ValueSource{"kv"}, - Lookup: StaticLookup{}.Lookup, - }, - }, - { - name: "default source forbidden in order", - options: ResolveOptions{ - Fields: []FieldSpec{{Name: "base_url"}}, - Order: []ValueSource{SourceDefault}, - Lookup: StaticLookup{}.Lookup, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - _, err := ResolveFields(tc.options) - if !errors.Is(err, ErrInvalidResolverInput) { - t.Fatalf("ResolveFields error = %v, want ErrInvalidResolverInput", err) - } - }) - } -} - -func TestRenderResolutionProvenance(t *testing.T) { - var out bytes.Buffer - err := RenderResolutionProvenance(&out, Resolution{ - Fields: []ResolvedField{ - {Name: "base_url", Source: SourceFlag, Found: true}, - {Name: "api_token", Found: false}, - }, - }) - if err != nil { - t.Fatalf("RenderResolutionProvenance returned error: %v", err) - } - - text := out.String() - if !strings.Contains(text, "base_url: flag") { - t.Fatalf("output = %q, want base_url provenance", text) - } - if !strings.Contains(text, "api_token: missing") { - t.Fatalf("output = %q, want missing provenance", text) - } -} - diff --git a/cli/setup.go b/cli/setup.go deleted file mode 100644 index 77eb8bb..0000000 --- a/cli/setup.go +++ /dev/null @@ -1,530 +0,0 @@ -package cli - -import ( - "bufio" - "errors" - "fmt" - "io" - "os" - "slices" - "strings" - - "golang.org/x/term" -) - -type SetupFieldType string - -const ( - SetupFieldString SetupFieldType = "string" - SetupFieldURL SetupFieldType = "url" - SetupFieldSecret SetupFieldType = "secret" - SetupFieldBool SetupFieldType = "bool" - SetupFieldList SetupFieldType = "list" -) - -var ErrInvalidSetupDefinition = errors.New("invalid setup definition") - -type SetupField struct { - Name string - Label string - Type SetupFieldType - Required bool - Default string - ExistingSecret string - ListSeparator string - Normalize func(string) string - Validate func(string) error - ValidateBool func(bool) error - ValidateList func([]string) error -} - -type SetupOptions struct { - Fields []SetupField - Stdin *os.File - Stdout io.Writer -} - -type SetupValue struct { - Type SetupFieldType - String string - Bool bool - List []string - Set bool - KeptStoredSecret bool -} - -type SetupResultField struct { - Name string - Value SetupValue -} - -type SetupResult struct { - Fields []SetupResultField -} - -func (r SetupResult) Get(name string) (SetupValue, bool) { - needle := strings.TrimSpace(name) - for _, field := range r.Fields { - if field.Name == needle { - return field.Value, true - } - } - - return SetupValue{}, false -} - -type SetupValidationError struct { - Field string - Label string - Message string -} - -func (e *SetupValidationError) Error() string { - label := strings.TrimSpace(e.Label) - if label == "" { - label = strings.TrimSpace(e.Field) - } - - if label == "" { - return strings.TrimSpace(e.Message) - } - - return fmt.Sprintf("%s: %s", label, strings.TrimSpace(e.Message)) -} - -type normalizedSetupField struct { - Name string - Label string - Type SetupFieldType - Required bool - DefaultString string - DefaultBool *bool - DefaultList []string - ExistingSecret string - ListSeparator string - Normalize func(string) string - Validate func(string) error - ValidateBool func(bool) error - ValidateList func([]string) error -} - -func RunSetup(options SetupOptions) (SetupResult, error) { - stdin, stdout := normalizeSetupIO(options) - - fields, err := normalizeSetupFields(options.Fields) - if err != nil { - return SetupResult{}, err - } - - reader := bufio.NewReader(stdin) - fd := int(stdin.Fd()) - isTTY := term.IsTerminal(fd) - - result := SetupResult{ - Fields: make([]SetupResultField, 0, len(fields)), - } - - for _, field := range fields { - value, err := promptSetupField(reader, stdin, stdout, fd, isTTY, field) - if err != nil { - return result, err - } - - result.Fields = append(result.Fields, SetupResultField{ - Name: field.Name, - Value: value, - }) - } - - return result, nil -} - -func normalizeSetupIO(options SetupOptions) (*os.File, io.Writer) { - stdin := options.Stdin - if stdin == nil { - stdin = os.Stdin - } - - stdout := options.Stdout - if stdout == nil { - stdout = os.Stdout - } - - return stdin, stdout -} - -func normalizeSetupFields(fields []SetupField) ([]normalizedSetupField, error) { - normalized := make([]normalizedSetupField, 0, len(fields)) - seenNames := make(map[string]struct{}, len(fields)) - - for i, field := range fields { - name := strings.TrimSpace(field.Name) - if name == "" { - return nil, fmt.Errorf("%w: field at index %d has empty name", ErrInvalidSetupDefinition, i) - } - if _, exists := seenNames[name]; exists { - return nil, fmt.Errorf("%w: duplicate field name %q", ErrInvalidSetupDefinition, name) - } - seenNames[name] = struct{}{} - - if !isKnownSetupFieldType(field.Type) { - return nil, fmt.Errorf("%w: field %q uses unknown type %q", ErrInvalidSetupDefinition, name, field.Type) - } - - label := strings.TrimSpace(field.Label) - if label == "" { - label = name - } - - normalizer := field.Normalize - if normalizer == nil { - normalizer = strings.TrimSpace - } - - listSeparator := field.ListSeparator - if listSeparator == "" { - listSeparator = "," - } - - entry := normalizedSetupField{ - Name: name, - Label: label, - Type: field.Type, - Required: field.Required, - ExistingSecret: strings.TrimSpace(field.ExistingSecret), - ListSeparator: listSeparator, - Normalize: normalizer, - Validate: field.Validate, - ValidateBool: field.ValidateBool, - ValidateList: field.ValidateList, - } - - switch field.Type { - case SetupFieldString, SetupFieldURL, SetupFieldSecret: - entry.DefaultString = normalizer(field.Default) - if field.Type == SetupFieldURL && entry.DefaultString != "" { - if err := ValidateBaseURL(entry.DefaultString); err != nil { - return nil, fmt.Errorf("%w: field %q default URL is invalid: %v", ErrInvalidSetupDefinition, name, err) - } - } - case SetupFieldBool: - defaultRaw := strings.TrimSpace(field.Default) - if defaultRaw != "" { - defaultValue, err := parseBoolValue(defaultRaw) - if err != nil { - return nil, fmt.Errorf("%w: field %q default bool is invalid: %v", ErrInvalidSetupDefinition, name, err) - } - entry.DefaultBool = &defaultValue - } - case SetupFieldList: - defaultRaw := strings.TrimSpace(field.Default) - if defaultRaw != "" { - entry.DefaultList = splitSetupList(defaultRaw, listSeparator, normalizer) - } - } - - normalized = append(normalized, entry) - } - - return normalized, nil -} - -func promptSetupField( - reader *bufio.Reader, - stdin *os.File, - stdout io.Writer, - fd int, - isTTY bool, - field normalizedSetupField, -) (SetupValue, error) { - for { - if err := renderSetupPrompt(stdout, field); err != nil { - return SetupValue{}, err - } - - raw, err := readSetupInput(reader, stdout, fd, isTTY, field.Type) - if err != nil { - return SetupValue{}, fmt.Errorf("read %q: %w", field.Name, err) - } - - value, validationErr := parseSetupValue(field, raw) - if validationErr == nil { - return value, nil - } - - setupErr := &SetupValidationError{ - Field: field.Name, - Label: field.Label, - Message: validationErr.Error(), - } - if !isTTY { - return SetupValue{}, setupErr - } - - if _, err := fmt.Fprintf(stdout, "Invalid value for %s: %s\n", field.Label, validationErr.Error()); err != nil { - return SetupValue{}, err - } - } -} - -func readSetupInput( - reader *bufio.Reader, - stdout io.Writer, - fd int, - isTTY bool, - fieldType SetupFieldType, -) (string, error) { - if fieldType == SetupFieldSecret && isTTY { - secret, err := term.ReadPassword(fd) - fmt.Fprintln(stdout) - if err != nil { - return "", err - } - return string(secret), nil - } - - line, err := reader.ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - return "", err - } - return line, nil -} - -func parseSetupValue(field normalizedSetupField, raw string) (SetupValue, error) { - switch field.Type { - case SetupFieldString: - return parseSetupStringValue(field, raw) - case SetupFieldURL: - value, err := parseSetupStringValue(field, raw) - if err != nil { - return SetupValue{}, err - } - if value.Set { - if err := ValidateBaseURL(value.String); err != nil { - return SetupValue{}, fmt.Errorf("must be a valid URL with scheme and host") - } - } - return value, nil - case SetupFieldSecret: - return parseSetupSecretValue(field, raw) - case SetupFieldBool: - return parseSetupBoolValue(field, raw) - case SetupFieldList: - return parseSetupListValue(field, raw) - default: - return SetupValue{}, fmt.Errorf("unsupported field type %q", field.Type) - } -} - -func parseSetupStringValue(field normalizedSetupField, raw string) (SetupValue, error) { - value := field.Normalize(raw) - set := value != "" - if !set && field.DefaultString != "" { - value = field.DefaultString - set = true - } - - if field.Required && !set { - return SetupValue{}, fmt.Errorf("value is required") - } - - if set && field.Validate != nil { - if err := field.Validate(value); err != nil { - return SetupValue{}, err - } - } - - return SetupValue{ - Type: field.Type, - String: value, - Set: set, - }, nil -} - -func parseSetupSecretValue(field normalizedSetupField, raw string) (SetupValue, error) { - value := field.Normalize(raw) - set := value != "" - keptStored := false - - if !set && field.ExistingSecret != "" { - value = field.ExistingSecret - set = true - keptStored = true - } else if !set && field.DefaultString != "" { - value = field.DefaultString - set = true - } - - if field.Required && !set { - return SetupValue{}, fmt.Errorf("value is required") - } - - if set && field.Validate != nil { - if err := field.Validate(value); err != nil { - return SetupValue{}, err - } - } - - return SetupValue{ - Type: field.Type, - String: value, - Set: set, - KeptStoredSecret: keptStored, - }, nil -} - -func parseSetupBoolValue(field normalizedSetupField, raw string) (SetupValue, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - if field.DefaultBool != nil { - value := *field.DefaultBool - if field.ValidateBool != nil { - if err := field.ValidateBool(value); err != nil { - return SetupValue{}, err - } - } - return SetupValue{ - Type: SetupFieldBool, - Bool: value, - Set: true, - }, nil - } - - if field.Required { - return SetupValue{}, fmt.Errorf("value is required") - } - - return SetupValue{ - Type: SetupFieldBool, - Set: false, - }, nil - } - - value, err := parseBoolValue(trimmed) - if err != nil { - return SetupValue{}, err - } - if field.ValidateBool != nil { - if err := field.ValidateBool(value); err != nil { - return SetupValue{}, err - } - } - - return SetupValue{ - Type: SetupFieldBool, - Bool: value, - Set: true, - }, nil -} - -func parseSetupListValue(field normalizedSetupField, raw string) (SetupValue, error) { - trimmed := strings.TrimSpace(raw) - - var list []string - set := false - if trimmed != "" { - list = splitSetupList(trimmed, field.ListSeparator, field.Normalize) - set = true - } else if len(field.DefaultList) > 0 { - list = slices.Clone(field.DefaultList) - set = true - } - - if field.Required && len(list) == 0 { - return SetupValue{}, fmt.Errorf("value is required") - } - - if field.ValidateList != nil { - if err := field.ValidateList(list); err != nil { - return SetupValue{}, err - } - } - - return SetupValue{ - Type: SetupFieldList, - List: list, - Set: set, - }, nil -} - -func renderSetupPrompt(w io.Writer, field normalizedSetupField) error { - switch field.Type { - case SetupFieldSecret: - if field.ExistingSecret != "" { - _, err := fmt.Fprintf(w, "%s [stored, leave blank to keep]: ", field.Label) - return err - } - if field.DefaultString != "" { - _, err := fmt.Fprintf(w, "%s [%s]: ", field.Label, field.DefaultString) - return err - } - _, err := fmt.Fprintf(w, "%s: ", field.Label) - return err - case SetupFieldBool: - defaultLabel := "y/n" - if field.DefaultBool != nil { - if *field.DefaultBool { - defaultLabel = "Y/n" - } else { - defaultLabel = "y/N" - } - } - _, err := fmt.Fprintf(w, "%s [%s]: ", field.Label, defaultLabel) - return err - case SetupFieldList: - if len(field.DefaultList) > 0 { - _, err := fmt.Fprintf( - w, - "%s [%s]: ", - field.Label, - strings.Join(field.DefaultList, field.ListSeparator), - ) - return err - } - _, err := fmt.Fprintf(w, "%s: ", field.Label) - return err - case SetupFieldString, SetupFieldURL: - if field.DefaultString != "" { - _, err := fmt.Fprintf(w, "%s [%s]: ", field.Label, field.DefaultString) - return err - } - _, err := fmt.Fprintf(w, "%s: ", field.Label) - return err - default: - _, err := fmt.Fprintf(w, "%s: ", field.Label) - return err - } -} - -func splitSetupList(raw, separator string, normalize func(string) string) []string { - parts := strings.Split(raw, separator) - list := make([]string, 0, len(parts)) - for _, part := range parts { - normalized := normalize(part) - if normalized == "" { - continue - } - list = append(list, normalized) - } - return list -} - -func parseBoolValue(raw string) (bool, error) { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "1", "t", "true", "y", "yes", "on": - return true, nil - case "0", "f", "false", "n", "no", "off": - return false, nil - default: - return false, fmt.Errorf("must be one of: yes/no, y/n, true/false, 1/0") - } -} - -func isKnownSetupFieldType(fieldType SetupFieldType) bool { - switch fieldType { - case SetupFieldString, SetupFieldURL, SetupFieldSecret, SetupFieldBool, SetupFieldList: - return true - default: - return false - } -} diff --git a/cli/setup_secret.go b/cli/setup_secret.go deleted file mode 100644 index dceaacd..0000000 --- a/cli/setup_secret.go +++ /dev/null @@ -1,76 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "strings" - - "gitea.lclr.dev/AI/mcp-framework/secretstore" -) - -type SetupSecretWriteOptions struct { - Store secretstore.Store - SecretName string - SecretLabel string - TokenEnv string - Value SetupValue -} - -func WriteSetupSecretVerified(options SetupSecretWriteOptions) error { - if options.Store == nil { - return errors.New("secret store must not be nil") - } - - secretName := strings.TrimSpace(options.SecretName) - if secretName == "" { - return errors.New("secret name must not be empty") - } - - secretLabel := strings.TrimSpace(options.SecretLabel) - if secretLabel == "" { - secretLabel = secretName - } - - if options.Value.KeptStoredSecret { - return verifyStoredSetupSecret(options.Store, secretName, options.TokenEnv) - } - if !options.Value.Set { - return nil - } - - if err := secretstore.SetSecretVerified(options.Store, secretName, secretLabel, options.Value.String); err != nil { - if errors.Is(err, secretstore.ErrReadOnly) { - tokenEnv := strings.TrimSpace(options.TokenEnv) - if tokenEnv != "" { - return fmt.Errorf("secret store is read-only, export %s and retry setup: %w", tokenEnv, err) - } - } - return fmt.Errorf("save secret %q during setup: %w", secretName, err) - } - - return nil -} - -func verifyStoredSetupSecret(store secretstore.Store, secretName, tokenEnv string) error { - secret, err := store.GetSecret(secretName) - if err != nil { - if errors.Is(err, secretstore.ErrNotFound) { - tokenEnv = strings.TrimSpace(tokenEnv) - if tokenEnv != "" { - return fmt.Errorf( - "secret %q is not readable after setup, export %s and retry: %w", - secretName, - tokenEnv, - err, - ) - } - } - return fmt.Errorf("verify secret %q after setup: %w", secretName, err) - } - - if strings.TrimSpace(secret) == "" { - return fmt.Errorf("secret %q is empty after setup", secretName) - } - - return nil -} diff --git a/cli/setup_secret_test.go b/cli/setup_secret_test.go deleted file mode 100644 index d426459..0000000 --- a/cli/setup_secret_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package cli - -import ( - "errors" - "strings" - "testing" - - "gitea.lclr.dev/AI/mcp-framework/secretstore" -) - -func TestWriteSetupSecretVerifiedPersistsAndConfirmsReadability(t *testing.T) { - store := &setupSecretStore{secrets: map[string]string{}} - - err := WriteSetupSecretVerified(SetupSecretWriteOptions{ - Store: store, - SecretName: "api-token", - SecretLabel: "API token", - Value: SetupValue{ - Type: SetupFieldSecret, - String: "secret-v1", - Set: true, - }, - }) - if err != nil { - t.Fatalf("WriteSetupSecretVerified returned error: %v", err) - } - - if got := store.secrets["api-token"]; got != "secret-v1" { - t.Fatalf("stored secret = %q, want secret-v1", got) - } -} - -func TestWriteSetupSecretVerifiedReturnsContextForReadOnlyStores(t *testing.T) { - store := &setupSecretStore{ - secrets: map[string]string{}, - setErr: secretstore.ErrReadOnly, - } - - err := WriteSetupSecretVerified(SetupSecretWriteOptions{ - Store: store, - SecretName: "api-token", - TokenEnv: "GRAYLOG_MCP_API_TOKEN", - Value: SetupValue{ - Type: SetupFieldSecret, - String: "secret-v1", - Set: true, - }, - }) - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, secretstore.ErrReadOnly) { - t.Fatalf("error = %v, want ErrReadOnly", err) - } - if !strings.Contains(err.Error(), "GRAYLOG_MCP_API_TOKEN") { - t.Fatalf("error = %v, want token env remediation", err) - } -} - -func TestWriteSetupSecretVerifiedValidatesKeptStoredSecret(t *testing.T) { - store := &setupSecretStore{ - secrets: map[string]string{}, - getErr: secretstore.ErrNotFound, - } - - err := WriteSetupSecretVerified(SetupSecretWriteOptions{ - Store: store, - SecretName: "api-token", - TokenEnv: "GRAYLOG_MCP_API_TOKEN", - Value: SetupValue{ - Type: SetupFieldSecret, - String: "stored-token", - Set: true, - KeptStoredSecret: true, - }, - }) - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, secretstore.ErrNotFound) { - t.Fatalf("error = %v, want ErrNotFound", err) - } - if !strings.Contains(err.Error(), "GRAYLOG_MCP_API_TOKEN") { - t.Fatalf("error = %v, want token env remediation", err) - } -} - -type setupSecretStore struct { - secrets map[string]string - setErr error - getErr error -} - -func (s *setupSecretStore) SetSecret(name, label, secret string) error { - if s.setErr != nil { - return s.setErr - } - s.secrets[name] = secret - return nil -} - -func (s *setupSecretStore) GetSecret(name string) (string, error) { - if s.getErr != nil { - return "", s.getErr - } - value, ok := s.secrets[name] - if !ok { - return "", secretstore.ErrNotFound - } - return value, nil -} - -func (s *setupSecretStore) DeleteSecret(name string) error { - delete(s.secrets, name) - return nil -} diff --git a/cli/setup_test.go b/cli/setup_test.go deleted file mode 100644 index 922079c..0000000 --- a/cli/setup_test.go +++ /dev/null @@ -1,265 +0,0 @@ -package cli - -import ( - "bytes" - "errors" - "os" - "slices" - "strings" - "testing" -) - -func TestRunSetupParsesTypedFieldsWithDefaultsAndNormalization(t *testing.T) { - stdin := setupTestInputFile(t, strings.Join([]string{ - " https://api.example.com ", - "", - "", - "Read, WRITE, admin", - " eu-west ", - }, "\n")+"\n") - - var stdout bytes.Buffer - result, err := RunSetup(SetupOptions{ - Stdin: stdin, - Stdout: &stdout, - Fields: []SetupField{ - { - Name: "base_url", - Label: "Base URL", - Type: SetupFieldURL, - Required: true, - }, - { - Name: "api_token", - Label: "API token", - Type: SetupFieldSecret, - Required: true, - ExistingSecret: "stored-token", - }, - { - Name: "enabled", - Label: "Enabled", - Type: SetupFieldBool, - Default: "true", - }, - { - Name: "scopes", - Label: "Scopes", - Type: SetupFieldList, - Default: "read,write", - Normalize: func(raw string) string { - return strings.ToLower(strings.TrimSpace(raw)) - }, - }, - { - Name: "region", - Label: "Region", - Type: SetupFieldString, - Default: "eu-central", - Normalize: strings.TrimSpace, - }, - }, - }) - if err != nil { - t.Fatalf("RunSetup returned error: %v", err) - } - - baseURL, ok := result.Get("base_url") - if !ok { - t.Fatalf("missing base_url field") - } - if !baseURL.Set || baseURL.String != "https://api.example.com" { - t.Fatalf("base_url = %#v, want normalized URL", baseURL) - } - - token, ok := result.Get("api_token") - if !ok { - t.Fatalf("missing api_token field") - } - if token.String != "stored-token" { - t.Fatalf("token.String = %q, want stored-token", token.String) - } - if !token.KeptStoredSecret { - t.Fatalf("token should keep stored secret when blank") - } - - enabled, ok := result.Get("enabled") - if !ok { - t.Fatalf("missing enabled field") - } - if !enabled.Bool || !enabled.Set { - t.Fatalf("enabled = %#v, want true from default", enabled) - } - - scopes, ok := result.Get("scopes") - if !ok { - t.Fatalf("missing scopes field") - } - wantScopes := []string{"read", "write", "admin"} - if !slices.Equal(scopes.List, wantScopes) { - t.Fatalf("scopes.List = %v, want %v", scopes.List, wantScopes) - } - - region, ok := result.Get("region") - if !ok { - t.Fatalf("missing region field") - } - if region.String != "eu-west" { - t.Fatalf("region.String = %q, want eu-west", region.String) - } - - promptOutput := stdout.String() - for _, needle := range []string{ - "Base URL:", - "API token [stored, leave blank to keep]:", - "Enabled [Y/n]:", - "Scopes [read,write]:", - "Region [eu-central]:", - } { - if !strings.Contains(promptOutput, needle) { - t.Fatalf("prompt output = %q, want substring %q", promptOutput, needle) - } - } -} - -func TestRunSetupReturnsReadableValidationErrorInNonInteractiveMode(t *testing.T) { - stdin := setupTestInputFile(t, "maybe\n") - var stdout bytes.Buffer - - _, err := RunSetup(SetupOptions{ - Stdin: stdin, - Stdout: &stdout, - Fields: []SetupField{ - { - Name: "enabled", - Label: "Enabled", - Type: SetupFieldBool, - }, - }, - }) - - var validationErr *SetupValidationError - if !errors.As(err, &validationErr) { - t.Fatalf("RunSetup error = %v, want SetupValidationError", err) - } - if validationErr.Field != "enabled" { - t.Fatalf("validationErr.Field = %q, want enabled", validationErr.Field) - } - if !strings.Contains(validationErr.Error(), "must be one of") { - t.Fatalf("validationErr = %v, want readable bool error", validationErr) - } -} - -func TestRunSetupSupportsValidationHooks(t *testing.T) { - stdin := setupTestInputFile(t, "https://example.com\nalpha,beta\n") - - _, err := RunSetup(SetupOptions{ - Stdin: stdin, - Fields: []SetupField{ - { - Name: "base_url", - Label: "Base URL", - Type: SetupFieldURL, - Validate: func(value string) error { - if !strings.HasSuffix(value, "/v1") { - return errors.New("must end with /v1") - } - return nil - }, - }, - { - Name: "scopes", - Type: SetupFieldList, - ValidateList: func(values []string) error { - if len(values) < 3 { - return errors.New("at least 3 scopes are required") - } - return nil - }, - }, - }, - }) - - var validationErr *SetupValidationError - if !errors.As(err, &validationErr) { - t.Fatalf("RunSetup error = %v, want SetupValidationError", err) - } - if validationErr.Field != "base_url" { - t.Fatalf("validationErr.Field = %q, want base_url", validationErr.Field) - } - if !strings.Contains(validationErr.Error(), "must end with /v1") { - t.Fatalf("validationErr = %v", validationErr) - } -} - -func TestRunSetupRejectsInvalidDefinitions(t *testing.T) { - tests := []struct { - name string - fields []SetupField - }{ - { - name: "empty field name", - fields: []SetupField{ - {Name: " ", Type: SetupFieldString}, - }, - }, - { - name: "duplicate field names", - fields: []SetupField{ - {Name: "base_url", Type: SetupFieldString}, - {Name: "base_url", Type: SetupFieldURL}, - }, - }, - { - name: "unknown type", - fields: []SetupField{ - {Name: "base_url", Type: SetupFieldType("json")}, - }, - }, - { - name: "invalid bool default", - fields: []SetupField{ - {Name: "enabled", Type: SetupFieldBool, Default: "sometimes"}, - }, - }, - { - name: "invalid url default", - fields: []SetupField{ - {Name: "base_url", Type: SetupFieldURL, Default: "localhost"}, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - _, err := RunSetup(SetupOptions{ - Stdin: setupTestInputFile(t, ""), - Fields: tc.fields, - }) - if !errors.Is(err, ErrInvalidSetupDefinition) { - t.Fatalf("RunSetup error = %v, want ErrInvalidSetupDefinition", err) - } - }) - } -} - -func setupTestInputFile(t *testing.T, content string) *os.File { - t.Helper() - - file, err := os.CreateTemp(t.TempDir(), "setup-input-*.txt") - if err != nil { - t.Fatalf("CreateTemp returned error: %v", err) - } - t.Cleanup(func() { - _ = file.Close() - }) - - if _, err := file.WriteString(content); err != nil { - t.Fatalf("WriteString returned error: %v", err) - } - if _, err := file.Seek(0, 0); err != nil { - t.Fatalf("Seek returned error: %v", err) - } - - return file -} diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go deleted file mode 100644 index bcc9345..0000000 --- a/cmd/mcp-framework/main.go +++ /dev/null @@ -1,269 +0,0 @@ -package main - -import ( - "errors" - "flag" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - generatepkg "gitea.lclr.dev/AI/mcp-framework/generate" - scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold" -) - -const toolName = "mcp-framework" - -func main() { - if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func run(args []string, stdout, stderr io.Writer) error { - if stdout == nil { - stdout = io.Discard - } - if stderr == nil { - stderr = io.Discard - } - - if len(args) == 0 || isHelpArg(args[0]) { - printGlobalHelp(stdout) - return nil - } - - switch args[0] { - case "generate": - return runGenerate(args[1:], stdout, stderr) - case "scaffold": - return runScaffold(args[1:], stdout, stderr) - default: - return fmt.Errorf("unknown command %q", args[0]) - } -} - -func runGenerate(args []string, stdout, stderr io.Writer) error { - if shouldShowHelp(args) { - printGenerateHelp(stdout) - return nil - } - - fs := flag.NewFlagSet("generate", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - var manifestPath string - var packageDir string - var packageName string - var check bool - - fs.StringVar(&manifestPath, "manifest", "", "Chemin du mcp.toml à lire (défaut: ./mcp.toml)") - fs.StringVar(&packageDir, "package-dir", "mcpgen", "Répertoire du package Go généré") - fs.StringVar(&packageName, "package-name", "", "Nom du package Go généré (défaut: dérivé du dossier)") - fs.BoolVar(&check, "check", false, "Échoue si les fichiers générés ne sont pas à jour") - - if err := fs.Parse(args); err != nil { - _ = stderr - return fmt.Errorf("parse generate flags: %w", err) - } - - if fs.NArg() > 0 { - return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) - } - - result, err := generatepkg.Generate(generatepkg.Options{ - ManifestPath: manifestPath, - PackageDir: packageDir, - PackageName: packageName, - Check: check, - }) - if err != nil { - return err - } - - if check { - if _, err := fmt.Fprintln(stdout, "Generated files are up to date"); err != nil { - return err - } - return nil - } - - for _, file := range result.Files { - if _, err := fmt.Fprintf(stdout, "Generated %s\n", filepath.ToSlash(file)); err != nil { - return err - } - } - - return nil -} - -func runScaffold(args []string, stdout, stderr io.Writer) error { - if len(args) == 0 || isHelpArg(args[0]) { - printScaffoldHelp(stdout) - return nil - } - - switch args[0] { - case "init": - return runScaffoldInit(args[1:], stdout, stderr) - default: - return fmt.Errorf("unknown scaffold subcommand %q", args[0]) - } -} - -func runScaffoldInit(args []string, stdout, stderr io.Writer) error { - if shouldShowHelp(args) { - printScaffoldInitHelp(stdout) - return nil - } - - fs := flag.NewFlagSet("scaffold init", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - var target string - var modulePath string - var binaryName string - var description string - var docsURL string - var defaultProfile string - var profiles string - var knownEnv string - var secretStorePolicy string - var releaseDriver string - var releaseBaseURL string - var releaseRepository string - var releaseTokenEnv string - var releasePublicKeyEnv string - var overwrite bool - - fs.StringVar(&target, "target", "", "Répertoire cible du nouveau projet (requis)") - fs.StringVar(&modulePath, "module", "", "Chemin de module Go du projet généré") - fs.StringVar(&binaryName, "binary", "", "Nom du binaire généré") - fs.StringVar(&description, "description", "", "Description bootstrap du binaire") - fs.StringVar(&docsURL, "docs-url", "", "URL de documentation du projet") - fs.StringVar(&defaultProfile, "default-profile", "", "Profil par défaut") - fs.StringVar(&profiles, "profiles", "", "Liste CSV de profils connus") - fs.StringVar(&knownEnv, "known-env", "", "Liste CSV de variables d'environnement connues") - fs.StringVar(&secretStorePolicy, "secret-store-policy", "", "Politique secret store (auto, keyring-any, kwallet-only, env-only)") - fs.StringVar(&releaseDriver, "release-driver", "", "Driver de release (gitea, gitlab, github)") - fs.StringVar(&releaseBaseURL, "release-base-url", "", "Base URL de la forge release") - fs.StringVar(&releaseRepository, "release-repository", "", "Repository release (org/repo)") - fs.StringVar(&releaseTokenEnv, "release-token-env", "", "Nom de variable d'environnement pour le token release") - fs.StringVar(&releasePublicKeyEnv, "release-pubkey-env", "", "Nom de variable d'environnement pour la cle publique Ed25519 de signature") - fs.BoolVar(&overwrite, "overwrite", false, "Autorise l'écrasement des fichiers existants") - - if err := fs.Parse(args); err != nil { - _ = stderr - return fmt.Errorf("parse scaffold init flags: %w", err) - } - - if fs.NArg() > 0 { - return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) - } - - if strings.TrimSpace(target) == "" { - return errors.New("--target is required") - } - - result, err := scaffoldpkg.Generate(scaffoldpkg.Options{ - TargetDir: target, - ModulePath: modulePath, - BinaryName: binaryName, - Description: description, - DocsURL: docsURL, - DefaultProfile: defaultProfile, - Profiles: parseCSV(profiles), - KnownEnvironmentVariables: parseCSV(knownEnv), - SecretStorePolicy: secretStorePolicy, - ReleaseDriver: releaseDriver, - ReleaseBaseURL: releaseBaseURL, - ReleaseRepository: releaseRepository, - ReleaseTokenEnv: releaseTokenEnv, - ReleasePublicKeyEnv: releasePublicKeyEnv, - Overwrite: overwrite, - }) - if err != nil { - return err - } - - if _, err := fmt.Fprintf(stdout, "Scaffold generated in %s\n", result.Root); err != nil { - return err - } - for _, file := range result.Files { - if _, err := fmt.Fprintf(stdout, "- %s\n", file); err != nil { - return err - } - } - - return nil -} - -func printGlobalHelp(w io.Writer) { - fmt.Fprintf( - w, - "Usage:\n %s [options]\n\nCommands:\n generate Génère la glue Go depuis mcp.toml\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", - toolName, - toolName, - ) -} - -func printGenerateHelp(w io.Writer) { - fmt.Fprintf( - w, - "Usage:\n %s generate [flags]\n\nFlags:\n --manifest Chemin du mcp.toml à lire\n --package-dir Répertoire du package Go généré (défaut: mcpgen)\n --package-name Nom du package Go généré (défaut: dérivé du dossier)\n --check Vérifie que les fichiers générés sont à jour\n", - toolName, - ) -} - -func printScaffoldHelp(w io.Writer) { - fmt.Fprintf( - w, - "Usage:\n %s scaffold init [flags]\n\nSubcommands:\n init Génère un nouveau squelette MCP\n", - toolName, - ) -} - -func printScaffoldInitHelp(w io.Writer) { - fmt.Fprintf( - w, - "Usage:\n %s scaffold init --target [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --release-pubkey-env Variable cle publique Ed25519 release\n --overwrite Écraser les fichiers existants\n", - toolName, - ) -} - -func shouldShowHelp(args []string) bool { - for _, arg := range args { - if isHelpArg(arg) { - return true - } - } - return false -} - -func isHelpArg(arg string) bool { - switch strings.TrimSpace(arg) { - case "-h", "--help", "help": - return true - default: - return false - } -} - -func parseCSV(value string) []string { - if strings.TrimSpace(value) == "" { - return nil - } - - parts := strings.Split(value, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed == "" { - continue - } - result = append(result, trimmed) - } - return result -} diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go deleted file mode 100644 index 7468085..0000000 --- a/cmd/mcp-framework/main_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package main - -import ( - "bytes" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestRunPrintsGlobalHelp(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - if err := run(nil, &stdout, &stderr); err != nil { - t.Fatalf("run returned error: %v", err) - } - - output := stdout.String() - if !strings.Contains(output, "mcp-framework ") { - t.Fatalf("global help should mention command usage: %q", output) - } - if !strings.Contains(output, "scaffold init") { - t.Fatalf("global help should mention scaffold init: %q", output) - } -} - -func TestRunScaffoldInitCreatesProject(t *testing.T) { - target := filepath.Join(t.TempDir(), "demo-mcp") - args := []string{ - "scaffold", "init", - "--target", target, - "--module", "example.com/demo-mcp", - "--binary", "demo-mcp", - "--profiles", "dev,prod", - } - - var stdout bytes.Buffer - var stderr bytes.Buffer - - if err := run(args, &stdout, &stderr); err != nil { - t.Fatalf("run returned error: %v", err) - } - - if _, err := os.Stat(filepath.Join(target, "cmd", "demo-mcp", "main.go")); err != nil { - t.Fatalf("generated main.go missing: %v", err) - } - if _, err := os.Stat(filepath.Join(target, "internal", "app", "app.go")); err != nil { - t.Fatalf("generated app.go missing: %v", err) - } - if _, err := os.Stat(filepath.Join(target, "mcp.toml")); err != nil { - t.Fatalf("generated mcp.toml missing: %v", err) - } - if _, err := os.Stat(filepath.Join(target, "install.sh")); err != nil { - t.Fatalf("generated install.sh missing: %v", err) - } - - if !strings.Contains(stdout.String(), "Scaffold generated in") { - t.Fatalf("stdout should include generation summary: %q", stdout.String()) - } -} - -func TestRunGenerateCreatesManifestLoader(t *testing.T) { - projectDir := t.TempDir() - if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(`binary_name = "demo-mcp"`), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := run([]string{"generate", "--manifest", filepath.Join(projectDir, "mcp.toml")}, &stdout, &stderr) - if err != nil { - t.Fatalf("run returned error: %v", err) - } - - if _, err := os.Stat(filepath.Join(projectDir, "mcpgen", "manifest.go")); err != nil { - t.Fatalf("generated manifest.go missing: %v", err) - } - if !strings.Contains(stdout.String(), "Generated mcpgen/manifest.go") { - t.Fatalf("stdout should include generation summary: %q", stdout.String()) - } -} - -func TestRunGenerateCheckReturnsErrorWhenOutdated(t *testing.T) { - projectDir := t.TempDir() - if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(`binary_name = "demo-mcp"`), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := run([]string{"generate", "--manifest", filepath.Join(projectDir, "mcp.toml"), "--check"}, &stdout, &stderr) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "generated files are not up to date") { - t.Fatalf("error = %v", err) - } -} - -func TestRunScaffoldInitRequiresTarget(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := run([]string{"scaffold", "init"}, &stdout, &stderr) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "--target is required") { - t.Fatalf("error = %v", err) - } -} - -func TestRunUnknownCommandReturnsError(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := run([]string{"boom"}, &stdout, &stderr) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "unknown command") { - t.Fatalf("error = %v", err) - } -} - -func TestScaffoldInitHelp(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - if err := run([]string{"scaffold", "init", "--help"}, &stdout, &stderr); err != nil { - t.Fatalf("run returned error: %v", err) - } - - output := stdout.String() - if !strings.Contains(output, "--target") { - t.Fatalf("init help should mention --target: %q", output) - } - if !strings.Contains(output, "--overwrite") { - t.Fatalf("init help should mention --overwrite: %q", output) - } -} diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 05527ee..0000000 --- a/docs/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Documentation mcp-framework - -`mcp-framework` fournit des packages Go et un CLI pour construire des binaires -MCP avec une base commune : bootstrap CLI, configuration, secrets, manifeste, -génération de code, scaffold, diagnostic et auto-update. - -## Navigation - -- [Installation et utilisation type](getting-started.md) -- [Packages](packages.md) -- [Bootstrap CLI](bootstrap-cli.md) -- [Manifeste `mcp.toml`](manifest.md) -- [Génération depuis `mcp.toml`](generate.md) -- [Scaffolding](scaffolding.md) -- [Config JSON](config.md) -- [Secrets](secrets.md) -- [Helpers CLI](cli-helpers.md) -- [Auto-update](auto-update.md) -- [Exemple minimal](minimal-example.md) -- [Limites](limitations.md) diff --git a/docs/auto-update.md b/docs/auto-update.md deleted file mode 100644 index 52ac5ab..0000000 --- a/docs/auto-update.md +++ /dev/null @@ -1,51 +0,0 @@ -# Auto-update - -Le package `update` supporte les drivers `gitea`, `gitlab` et `github`. -Si `latest_release_url` est vide, l'URL latest est déduite depuis `driver + repository (+ base_url)`. - -Le parseur de release supporte : - -- format `assets.links` (Gitea/GitLab) -- format `assets[]` avec `browser_download_url` (GitHub et Gitea API) - -Le format attendu pour la réponse `latest release` est : - -```json -{ - "tag_name": "v1.2.3", - "assets": { - "links": [ - { - "name": "my-mcp-linux-amd64", - "url": "https://example.com/downloads/my-mcp-linux-amd64" - } - ] - } -} -``` - -Exemple : - -```go -file, _, err := manifest.LoadDefault(".") -if err != nil { - return err -} - -err = update.Run(ctx, update.Options{ - CurrentVersion: version, - BinaryName: "my-mcp", - ReleaseSource: file.Update.ReleaseSource(), - Stdout: os.Stdout, -}) -if err != nil { - return err -} -``` - -Comportement : - -- le nom de l'asset est configurable (`asset_name_template`) et supporte tout couple `GOOS/GOARCH` -- si un asset `.sha256` (ou `checksum_asset_name`) existe, le binaire téléchargé est vérifié avant remplacement -- un hook `ValidateDownloaded` permet d'ajouter une validation custom (signature, scan, etc.) -- sur Windows, le remplacement in-place n'est pas fait par défaut ; fournir `Options.ReplaceExecutable` pour une stratégie dédiée diff --git a/docs/bootstrap-cli.md b/docs/bootstrap-cli.md deleted file mode 100644 index 403de43..0000000 --- a/docs/bootstrap-cli.md +++ /dev/null @@ -1,51 +0,0 @@ -# Bootstrap CLI - -Le package `bootstrap` reste optionnel : une application peut l'utiliser pour uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique. - -Exemple minimal : - -```go -func main() { - err := bootstrap.Run(context.Background(), bootstrap.Options{ - BinaryName: "my-mcp", - Description: "Client MCP", - Version: version, - EnableDoctorAlias: true, // expose `doctor` comme alias de `config test` - AliasDescriptions: map[string]string{ - "doctor": "Diagnostiquer la configuration locale.", - }, - Hooks: bootstrap.Hooks{ - Setup: func(ctx context.Context, inv bootstrap.Invocation) error { - return runSetup(ctx, inv.Args) - }, - Login: func(ctx context.Context, inv bootstrap.Invocation) error { - return runLogin(ctx, inv.Args) - }, - MCP: func(ctx context.Context, inv bootstrap.Invocation) error { - return runMCP(ctx, inv.Args) - }, - ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error { - return runConfigShow(ctx, inv.Args) - }, - ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error { - return runConfigTest(ctx, inv.Args) - }, - Update: func(ctx context.Context, inv bootstrap.Invocation) error { - return runUpdate(ctx, inv.Args) - }, - }, - }) - if err != nil { - log.Fatal(err) - } -} -``` - -Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`. - -Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). -La commande `login` est optionnelle et peut être branchée pour gérer un unlock Bitwarden interactif. -La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). -Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. -Le flag global `--debug` est supporté et active le debug des appels Bitwarden -(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`). diff --git a/docs/cli-helpers.md b/docs/cli-helpers.md deleted file mode 100644 index 7f59080..0000000 --- a/docs/cli-helpers.md +++ /dev/null @@ -1,214 +0,0 @@ -# Helpers CLI - -`cli` fournit des helpers simples pour les assistants interactifs : - -```go -profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) - -baseURL, err := cli.PromptLine(reader, os.Stdout, "Base URL", profile.BaseURL) -if err != nil { - return err -} -if err := cli.ValidateBaseURL(baseURL); err != nil { - return err -} - -token, err := cli.PromptSecret(os.Stdin, os.Stdout, "API token", hasStoredSecret, storedToken) -if err != nil { - return err -} -_ = token -``` - -Pour décrire un setup complet sans réécrire la boucle interactive : - -```go -result, err := cli.RunSetup(cli.SetupOptions{ - Stdin: os.Stdin, - Stdout: os.Stdout, - Fields: []cli.SetupField{ - { - Name: "base_url", - Label: "Base URL", - Type: cli.SetupFieldURL, - Required: true, - }, - { - Name: "api_token", - Label: "API token", - Type: cli.SetupFieldSecret, - Required: true, - ExistingSecret: storedToken, - }, - { - Name: "enabled", - Label: "Enable integration", - Type: cli.SetupFieldBool, - Default: "true", - }, - { - Name: "scopes", - Label: "Scopes", - Type: cli.SetupFieldList, - Default: "read,write", - Normalize: func(value string) string { return strings.TrimSpace(strings.ToLower(value)) }, - }, - }, -}) -if err != nil { - return err -} - -baseURL, _ := result.Get("base_url") -apiToken, _ := result.Get("api_token") -enabled, _ := result.Get("enabled") -scopes, _ := result.Get("scopes") - -if apiToken.KeptStoredSecret { - fmt.Println("Stored token kept.") -} - -_ = baseURL -_ = enabled -_ = scopes -``` - -Pour persister un secret de setup avec write+read-back et messages homogènes : - -```go -if err := cli.WriteSetupSecretVerified(cli.SetupSecretWriteOptions{ - Store: store, - SecretName: "api-token", - SecretLabel: "API token", - TokenEnv: "MY_MCP_API_TOKEN", - Value: apiToken, -}); err != nil { - return err -} -``` - -Chaque champ peut déclarer ses propres hooks de validation (`Validate`, `ValidateBool`, `ValidateList`). -Les validations sont appliquées de manière cohérente en TTY et en stdin non interactif. - -Pour standardiser la résolution `flag > env > config > secret` avec provenance : - -```go -store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ - ServiceName: "my-mcp", -}) -if err != nil { - return err -} - -lookup := cli.ResolveLookup(cli.ResolveLookupOptions{ - Flag: cli.MapLookup(flagValues), - Env: cli.EnvLookup(os.LookupEnv), - Config: cli.ConfigMap(configValues), - Secret: cli.SecretStore(store), -}) - -resolution, err := cli.ResolveFields(cli.ResolveOptions{ - Fields: []cli.FieldSpec{ - {Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"}, - {Name: "api_token", Required: true, EnvKey: "MY_MCP_API_TOKEN", SecretKey: "my-mcp-api-token"}, - {Name: "timeout", DefaultValue: "30s"}, - }, - Lookup: lookup, -}) -if err != nil { - return err -} - -if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil { - return err -} -``` - -`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et `FieldSpec.Sources` permet de définir un ordre spécifique pour un champ. - -Le package fournit aussi un socle réutilisable pour une commande `doctor`. -L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), mais peut réutiliser les checks communs et ajouter ses propres hooks : - -```go -report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ - ConfigCheck: cli.NewConfigCheck(store), - SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) { - return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ - ServiceName: "my-mcp", - }) - }), - SecretBackendPolicy: secretstore.BackendBitwardenCLI, - BitwardenOptions: cli.BitwardenDoctorOptions{ - LookupEnv: os.LookupEnv, - }, - RequiredSecrets: []cli.DoctorSecret{ - {Name: "api-token", Label: "API token"}, - }, - SecretStoreFactory: func() (secretstore.Store, error) { - return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ - ServiceName: "my-mcp", - }) - }, - ManifestDir: ".", - ConnectivityCheck: func(context.Context) cli.DoctorResult { - if err := pingBackend(); err != nil { - return cli.DoctorResult{ - Name: "connectivity", - Status: cli.DoctorStatusFail, - Summary: "backend is unreachable", - Detail: err.Error(), - } - } - return cli.DoctorResult{ - Name: "connectivity", - Status: cli.DoctorStatusOK, - Summary: "backend is reachable", - } - }, -}) - -if err := cli.RenderDoctorReport(os.Stdout, report); err != nil { - return err -} - -if report.HasFailures() { - os.Exit(1) -} -``` - -Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement. -Pour le désactiver explicitement : - -```go -report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ - DisableAutoBitwardenCheck: true, -}) -``` - -Pour éviter des checks custom répétitifs sur les profils, `doctor` expose aussi un helper basé sur `FieldSpec` : - -```go -report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ - ExtraChecks: []cli.DoctorCheck{ - cli.RequiredResolvedFieldsCheck(cli.ResolveOptions{ - Fields: []cli.FieldSpec{ - {Name: "base_url", Required: true, EnvKey: "MY_MCP_BASE_URL"}, - { - Name: "api_token", - Required: true, - EnvKey: "MY_MCP_API_TOKEN", - SecretKey: "my-mcp-api-token", - Sources: []cli.ValueSource{cli.SourceEnv, cli.SourceSecret}, - }, - }, - Lookup: cli.ResolveLookup(cli.ResolveLookupOptions{ - Env: cli.EnvLookup(os.LookupEnv), - Config: cli.ConfigMap(configValues), - Secret: cli.SecretStore(secretStore), - }), - }), - }, -}) -``` - -En cas d'échec de résolution, tu peux aussi réutiliser le formatteur `cli.FormatResolveFieldsError(err)` dans un check custom pour garder des messages homogènes. diff --git a/docs/config.md b/docs/config.md deleted file mode 100644 index 863f14c..0000000 --- a/docs/config.md +++ /dev/null @@ -1,64 +0,0 @@ -# Config JSON - -Le package `config` stocke une structure générique par profil dans un JSON privé pour l'utilisateur courant. - -Exemple : - -```go -type Profile struct { - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` -} - -store := config.NewStore[Profile]("my-mcp") - -cfg, path, err := store.LoadDefault() -if err != nil { - return err -} - -profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) -profile := cfg.Profiles[profileName] - -profile.BaseURL = "https://api.example.com" -cfg.CurrentProfile = profileName -cfg.Profiles[profileName] = profile - -_, err = store.SaveDefault(cfg) -if err != nil { - return err -} - -fmt.Printf("config saved to %s\n", path) -``` - -Notes : - -- le fichier est créé avec des permissions `0600` -- le répertoire parent est forcé en `0700` -- l'écriture est atomique via un fichier temporaire puis `rename` -- si le fichier n'existe pas, `Load` et `LoadDefault` retournent une config vide par défaut -- `NewStoreWithOptions` permet de définir une version cible, des migrations JSON (`from -> to`) et une validation explicite après chargement -- une config plus récente que la version supportée, ou sans chemin de migration complet, retourne une erreur explicite - -Exemple de store versionné : - -```go -store := config.NewStoreWithOptions[Profile]("my-mcp", config.Options[Profile]{ - Version: 2, - Migrations: map[int]config.Migration{ - 1: func(doc map[string]json.RawMessage) error { - return nil - }, - }, - Validator: func(cfg config.FileConfig[Profile]) []config.ValidationIssue { - if cfg.CurrentProfile == "" { - return []config.ValidationIssue{{ - Path: "current_profile", - Message: "must not be empty", - }} - } - return nil - }, -}) -``` diff --git a/docs/generate.md b/docs/generate.md deleted file mode 100644 index 5defc61..0000000 --- a/docs/generate.md +++ /dev/null @@ -1,150 +0,0 @@ -# Génération depuis `mcp.toml` - -La commande `mcp-framework generate` génère la glue Go dérivée du manifeste -racine d'un projet existant. - -## Usage - -Depuis la racine du projet Go : - -```bash -mcp-framework generate -``` - -La commande lit `./mcp.toml`, valide son contenu avec le package `manifest`, et -génère : - -```text -mcpgen/ - manifest.go - metadata.go - update.go - secretstore.go - config.go # si [[config.fields]] existe -``` - -Le package généré expose le loader de manifeste : - -```go -func LoadManifest(startDir string) (manifest.File, string, error) -``` - -Cette fonction appelle `manifest.LoadDefaultOrEmbedded`. En développement, un -`mcp.toml` présent sur disque reste prioritaire. Pour un binaire copié seul, -elle utilise le contenu du manifeste embarqué au moment de la génération. - -Il expose aussi des helpers dérivés du manifeste : - -```go -const BinaryName = "my-mcp" -const DefaultDescription = "..." -const DocsURL = "..." - -func BootstrapInfo(startDir string) (manifest.BootstrapMetadata, string, error) -func ScaffoldInfo(startDir string) (manifest.ScaffoldMetadata, string, error) -``` - -Pour l'auto-update : - -```go -func UpdateOptions(version string, stdout io.Writer) (update.Options, error) -func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (update.Options, error) -func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error -func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error -``` - -`RunUpdate` parse les flags de la commande `update`, refuse les arguments -positionnels, charge le manifeste via `LoadManifest`, puis appelle -`update.Run`. - -Pour les secrets : - -```go -type SecretStoreOptions struct { - ServiceName string - LookupEnv func(string) (string, bool) -} - -func OpenSecretStore(options SecretStoreOptions) (secretstore.Store, error) -func DescribeSecretRuntime(options SecretStoreOptions) (secretstore.RuntimeDescription, error) -func PreflightSecretStore(options SecretStoreOptions) (secretstore.PreflightReport, error) -``` - -`SecretStoreOptions` contient aussi les options techniques du package -`secretstore` (`KWalletAppID`, `KWalletFolder`, `BitwardenCommand`, -`BitwardenDebug`, `Shell`, `ExecutableResolver`). Si `ServiceName` est vide, -le nom du binaire déclaré dans le manifeste est utilisé. - -Si le manifest déclare `[[config.fields]]`, le package généré expose aussi : - -```go -type ConfigFlags struct { /* champs internes */ } - -func AddConfigFlags(fs *flag.FlagSet) ConfigFlags -func ConfigFlagValues(flags ConfigFlags) map[string]string -func ResolveFieldSpecs(profile string) []cli.FieldSpec -func SetupFields(existing map[string]string) []cli.SetupField -``` - -`AddConfigFlags` branche les flags déclarés sur le `FlagSet` du projet. -`ConfigFlagValues` retourne uniquement les valeurs de flags non vides. -`ResolveFieldSpecs` génère les specs à passer à `cli.ResolveFields`, en -remplaçant `{profile}` dans les templates de secrets. `SetupFields` génère les -champs attendus par `cli.RunSetup`; le paramètre `existing` permet de fournir -les secrets déjà stockés par nom de champ. - -## Flags - -- `--manifest` : chemin du `mcp.toml` à lire. Par défaut, `./mcp.toml`. -- `--package-dir` : répertoire du package généré. Par défaut, `mcpgen`. -- `--package-name` : nom du package Go généré. Par défaut, dérivé du dossier. -- `--check` : mode CI, échoue si les fichiers générés sont absents ou obsolètes. - -Exemple CI : - -```bash -mcp-framework generate --check -``` - -## Utilisation dans l'application - -Importer le package généré depuis le module de l'application : - -```go -import "example.com/my-mcp/mcpgen" -``` - -Charger le manifeste : - -```go -file, source, err := mcpgen.LoadManifest(".") -if err != nil { - return err -} -_ = file -_ = source -``` - -Construire les options d'update : - -```go -opts, err := mcpgen.UpdateOptions(version, os.Stdout) -if err != nil { - return err -} -``` - -Ouvrir le secret store configuré par le manifeste : - -```go -store, err := mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{ - LookupEnv: os.LookupEnv, -}) -if err != nil { - return err -} -_ = store -``` - -Après génération, un simple `go build ./...` suffit. La compilation ne dépend -pas de la commande `mcp-framework`. diff --git a/docs/getting-started.md b/docs/getting-started.md deleted file mode 100644 index d4c5dd0..0000000 --- a/docs/getting-started.md +++ /dev/null @@ -1,48 +0,0 @@ -# Installation et utilisation type - -## Installation - -```bash -go get gitea.lclr.dev/AI/mcp-framework -``` - -## CLI de scaffold - -Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go : - -```bash -go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest -mcp-framework scaffold init \ - --target ./my-mcp \ - --module example.com/my-mcp \ - --binary my-mcp \ - --profiles dev,prod -``` - -Puis dans le projet généré : - -```bash -cd my-mcp -go mod tidy -go run ./cmd/my-mcp help -``` - -## Utilisation type - -Un flux complet côté application : - -1. Déclarer `mcp.toml` à la racine du module. -2. Lancer `mcp-framework generate` pour produire le package `mcpgen`. -3. Déclarer les sous-commandes communes via `bootstrap` si l'application utilise le bootstrap CLI. -4. Résoudre le profil actif avec `cli`. -5. Charger la config versionnée avec `config`. -6. Lire les secrets avec `secretstore` ou `mcpgen.OpenSecretStore`. -7. Charger le manifest runtime avec `mcpgen.LoadManifest`. -8. Exécuter l'auto-update avec `mcpgen.RunUpdate` ou `update.Run`. -9. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. - -Pour vérifier que le code généré est synchronisé avec le manifeste : - -```bash -mcp-framework generate --check -``` diff --git a/docs/limitations.md b/docs/limitations.md deleted file mode 100644 index 915e894..0000000 --- a/docs/limitations.md +++ /dev/null @@ -1,3 +0,0 @@ -# Limites - -- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes diff --git a/docs/manifest.md b/docs/manifest.md deleted file mode 100644 index 8311969..0000000 --- a/docs/manifest.md +++ /dev/null @@ -1,133 +0,0 @@ -# Manifeste `mcp.toml` - -Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire courant puis remonte les répertoires parents jusqu'à trouver le fichier. -Pour un binaire installé (par exemple dans `~/.local/bin`), il peut aussi charger un fallback embarqué via `LoadDefaultOrEmbedded`. - -Exemple minimal : - -```toml -binary_name = "my-mcp" -docs_url = "https://docs.example.com/my-mcp" - -[update] -source_name = "Gitea releases" -driver = "gitea" -repository = "org/repo" -base_url = "https://gitea.example.com" -asset_name_template = "{binary}-{os}-{arch}{ext}" -checksum_asset_name = "{asset}.sha256" -checksum_required = true -signature_asset_name = "{asset}.sig" -signature_required = false -signature_public_key_env_names = ["MY_MCP_RELEASE_ED25519_PUBLIC_KEY"] -token_header = "Authorization" -token_prefix = "token" -token_env_names = ["GITEA_TOKEN"] - -[environment] -known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"] - -[secret_store] -backend_policy = "auto" -# Optionnel : mettre false pour désactiver le cache Bitwarden. -bitwarden_cache = true - -[profiles] -default = "prod" -known = ["dev", "staging", "prod"] - -[bootstrap] -description = "Client MCP interne" - -[[config.fields]] -name = "base_url" -flag = "base-url" -env = "MY_MCP_URL" -config_key = "base_url" -type = "url" -label = "Base URL" -required = true -sources = ["flag", "env", "config"] - -[[config.fields]] -name = "api_token" -flag = "api-token" -env = "MY_MCP_TOKEN" -secret_key_template = "profile/{profile}/api-token" -type = "secret" -label = "API token" -required = true -sources = ["flag", "env", "secret"] -``` - -Champs supportés : - -- `binary_name` : nom du binaire (utilisable par le bootstrap/scaffolding). -- `docs_url` : URL de documentation projet. -- `[update]` : source de release consommée par `update`. -- `source_name` : nom humain de la source de release, utilisé dans certains messages d'erreur. -- `driver` : driver de forge (`gitea`, `gitlab`, `github`) pour déduire automatiquement l'endpoint latest. -- `repository` : dépôt cible (`org/repo` ou `group/subgroup/repo`). -- `base_url` : base de la forge ou du service de release. -- `latest_release_url` : URL complète qui retourne la release la plus récente (prioritaire sur le driver). -- `asset_name_template` : template de nom d'asset (`{binary}`, `{os}`, `{arch}`, `{ext}`). -- `checksum_asset_name` : nom d'asset checksum, avec placeholder optionnel `{asset}`. -- `checksum_required` : si `true`, l'update échoue quand l'asset checksum est absent. -- `signature_asset_name` : nom d'asset signature Ed25519 (détachée), avec placeholder optionnel `{asset}`. -- `signature_required` : si `true`, l'update échoue si la signature ou la clé publique manquent, ou si la signature est invalide. -- `signature_public_key` : clé publique Ed25519 (hex ou base64) utilisée pour vérifier la signature. -- `signature_public_key_env_names` : variables d'environnement candidates contenant la clé publique Ed25519. -- `token_header` : header HTTP à utiliser pour l'authentification. -- `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...). -- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token. -- `[environment].known` : variables d'environnement connues du projet. -- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`, `bitwarden-cli`). -- `[secret_store].bitwarden_cache` : active le cache Bitwarden mémoire et disque chiffré quand `backend_policy = "bitwarden-cli"`. Par défaut, le cache est activé si le champ est absent. Mettre `false` pour le désactiver. -- `[profiles].default` : profil recommandé par défaut. -- `[profiles].known` : profils connus du projet. -- `[bootstrap].description` : description CLI utilisée par le bootstrap. -- `[[config.fields]]` : champs de configuration déclaratifs consommés par - `mcp-framework generate`. -- `name` : identifiant stable du champ. -- `flag` : nom du flag CLI, sans `--`. -- `env` : variable d'environnement associée. -- `config_key` : clé dans la config fichier du projet. -- `secret_key_template` : clé de secret, avec `{profile}` remplacé par le - profil courant dans le code généré. -- `type` : type de setup (`string`, `url`, `secret`, `bool`, `list`). -- `label` : libellé humain utilisé pendant le setup. -- `default` : valeur par défaut optionnelle. -- `required` : si `true`, la résolution échoue quand aucune source ne fournit - de valeur. -- `sources` : ordre de résolution spécifique au champ (`flag`, `env`, - `config`, `secret`). - -Toutes ces sections (hors `[update]` selon les besoins de l'application) sont optionnelles. - -Exemple de chargement : - -```go -file, path, err := manifest.LoadDefault(".") -if err != nil { - return err -} - -fmt.Printf("manifest loaded from %s\n", path) -source := file.Update.ReleaseSource() -bootstrapInfo := file.BootstrapInfo() -scaffoldInfo := file.ScaffoldInfo() -_ = bootstrapInfo -_ = scaffoldInfo -_ = source -``` - -Fallback fichier puis embarqué : - -```go -file, source, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest) -if err != nil { - return err -} - -fmt.Printf("manifest source: %s\n", source) // chemin du fichier ou "embedded:mcp.toml" -``` diff --git a/docs/minimal-example.md b/docs/minimal-example.md deleted file mode 100644 index 6c4c9f5..0000000 --- a/docs/minimal-example.md +++ /dev/null @@ -1,63 +0,0 @@ -# Exemple minimal - -Cet exemple suppose qu'un `mcp.toml` existe à la racine du module et que le -package généré est à jour : - -```bash -mcp-framework generate -``` - -Exemple de runner Go : - -```go -package main - -import ( - "context" - "fmt" - "os" - - "example.com/my-mcp/mcpgen" - "gitea.lclr.dev/AI/mcp-framework/config" - "gitea.lclr.dev/AI/mcp-framework/update" -) - -var version = "dev" - -type Profile struct { - BaseURL string `json:"base_url"` -} - -func main() { - if err := run(context.Background()); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run(ctx context.Context) error { - cfgStore := config.NewStore[Profile](mcpgen.BinaryName) - - cfg, _, err := cfgStore.LoadDefault() - if err != nil { - return err - } - - _, source, err := mcpgen.LoadManifest(".") - if err != nil { - return err - } - fmt.Println("manifest:", source) - - updateOptions, err := mcpgen.UpdateOptions(version, os.Stdout) - if err != nil { - return err - } - if err := update.Run(ctx, updateOptions); err != nil { - return err - } - - _ = cfg - return nil -} -``` diff --git a/docs/packages.md b/docs/packages.md deleted file mode 100644 index 83705cd..0000000 --- a/docs/packages.md +++ /dev/null @@ -1,10 +0,0 @@ -# Packages - -- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `login`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. -- `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`. -- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`. -- `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. -- `generate` : génération de code Go depuis `mcp.toml` (`mcpgen/manifest.go`, metadata, update, secret store, config fields). -- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage). -- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helpers runtime `OpenFromManifest`, `DescribeRuntime`, `PreflightFromManifest` et formatage homogène via `FormatBackendStatus`. -- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. diff --git a/docs/scaffolding.md b/docs/scaffolding.md deleted file mode 100644 index ee20762..0000000 --- a/docs/scaffolding.md +++ /dev/null @@ -1,26 +0,0 @@ -# Scaffolding - -Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : - -- arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) -- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI, setup local et export JSON MCP -- wiring initial `bootstrap + config + secretstore + update` -- `README.md` de démarrage - -Exemple : - -```go -result, err := scaffold.Generate(scaffold.Options{ - TargetDir: "./my-mcp", - ModulePath: "gitea.lclr.dev/AI/my-mcp", - BinaryName: "my-mcp", - Description: "Client MCP interne", - DefaultProfile: "prod", - Profiles: []string{"dev", "prod"}, -}) -if err != nil { - return err -} - -fmt.Printf("Scaffold generated in %s (%d files)\n", result.Root, len(result.Files)) -``` diff --git a/docs/secrets.md b/docs/secrets.md deleted file mode 100644 index 724419b..0000000 --- a/docs/secrets.md +++ /dev/null @@ -1,273 +0,0 @@ -# Secrets - -Le package `secretstore` supporte plusieurs politiques de backend : - -- `auto` : comportement par défaut, utilise un backend keyring disponible et peut retomber sur l'environnement si `LookupEnv` est fourni -- `kwallet-only` : impose KWallet et retourne une erreur explicite si KWallet n'est pas disponible -- `keyring-any` : impose l'utilisation d'un backend keyring disponible -- `env-only` : lecture seule depuis les variables d'environnement -- `bitwarden-cli` : utilise le CLI Bitwarden (`bw`) comme backend de vault - -Backends keyring typiques : - -- macOS : Keychain -- Linux : Secret Service ou KWallet selon l'environnement -- Windows : Credential Manager - -Ouverture recommandée depuis la policy du manifeste runtime (`mcp.toml` résolu près de l'exécutable) : - -```go -store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ - ServiceName: "my-mcp", - LookupEnv: os.LookupEnv, -}) -if err != nil { - return err -} -``` - -Exemple bas niveau : - -```go -store, err := secretstore.Open(secretstore.Options{ - ServiceName: "my-mcp", - BackendPolicy: secretstore.BackendAuto, -}) -if err != nil { - return err -} - -if err := store.SetSecret("api-token", "My MCP API token", token); err != nil { - return err -} - -token, err = store.GetSecret("api-token") -switch { -case err == nil: - // secret found -case errors.Is(err, secretstore.ErrNotFound): - // first run -default: - return err -} -``` - -Pour imposer KWallet sur Linux : - -```go -store, err := secretstore.Open(secretstore.Options{ - ServiceName: "my-mcp", - BackendPolicy: secretstore.BackendKWalletOnly, -}) -``` - -Pour imposer Bitwarden via son CLI : - -```go -store, err := secretstore.Open(secretstore.Options{ - ServiceName: "my-mcp", - BackendPolicy: secretstore.BackendBitwardenCLI, - // Optionnel si `bw` n'est pas dans le PATH : - // BitwardenCommand: "/usr/local/bin/bw", -}) -``` - -## Cache Bitwarden - -Le backend `bitwarden-cli` met en cache les lectures de secrets par défaut. -Le cache mémoire évite les appels répétés au CLI dans un même process. Le cache -disque est chiffré avec une clé dérivée de `BW_SESSION` via HKDF-SHA256 et -AES-GCM. - -TTL par défaut : 10 minutes. - -Pour désactiver le cache dans `mcp.toml` : - -```toml -[secret_store] -backend_policy = "bitwarden-cli" -bitwarden_cache = false -``` - -Pour le désactiver sans modifier le manifeste : - -```bash -MCP_FRAMEWORK_BITWARDEN_CACHE=0 -``` - -Le fichier de cache et le binaire installé ne suffisent pas à déchiffrer les -secrets. Si `BW_SESSION` change ou disparaît, les entrées disque existantes -deviennent inutilisables. Cette protection ne couvre pas un attaquant qui peut -lire l'environnement ou la mémoire du process pendant l'exécution. - -Pour vérifier explicitement que Bitwarden est prêt (login + unlock + `BW_SESSION`) : - -```go -if err := secretstore.EnsureBitwardenReady(secretstore.Options{ - BitwardenCommand: "bw", - LookupEnv: os.LookupEnv, -}); err != nil { - switch { - case errors.Is(err, secretstore.ErrBWNotLoggedIn): - // guider vers `bw login` - case errors.Is(err, secretstore.ErrBWLocked): - // guider vers `bw unlock --raw` puis export BW_SESSION - default: - // indisponibilité CLI/réseau - } - return err -} -``` - -Pour lancer un flux interactif `bw login` / `bw unlock --raw`, récupérer `BW_SESSION` -et le persister localement (fichier `0600` sous le répertoire de config utilisateur) : - -```go -session, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ - ServiceName: "my-mcp", - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, -}) -if err != nil { - return err -} -fmt.Println("session chargée:", len(session) > 0) -``` - -Pour réinjecter automatiquement une session persistée dans l'environnement courant : - -```go -loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ - ServiceName: "my-mcp", -}) -if err != nil { - return err -} -fmt.Println("session restaurée depuis disque:", loaded) -``` - -Pour stocker un secret structuré en JSON : - -```go -type Credentials struct { - Host string `json:"host"` - Username string `json:"username"` - Password string `json:"password"` -} - -err = secretstore.SetJSON(store, "imap-credentials", "IMAP credentials", Credentials{ - Host: "imap.example.com", - Username: "alice", - Password: token, -}) -if err != nil { - return err -} - -creds, err := secretstore.GetJSON[Credentials](store, "imap-credentials") -if err != nil { - return err -} -_ = creds -``` - -Pour écrire puis confirmer immédiatement une relecture : - -```go -if err := secretstore.SetSecretVerified(store, "api-token", "My MCP API token", token); err != nil { - return err -} -``` - -Pour connaître le backend effectif utilisé : - -```go -effective := secretstore.EffectiveBackendPolicy(store) -fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any... -``` - -Pour obtenir en un seul appel une description runtime légère (source manifeste, -policy déclarée/effective, backend affiché) : - -```go -desc, err := secretstore.DescribeRuntime(secretstore.DescribeRuntimeOptions{ - ServiceName: "my-mcp", - LookupEnv: os.LookupEnv, -}) -if err != nil { - return err -} - -fmt.Println(secretstore.FormatBackendStatus(desc)) -// declared=... effective=... display=... ready=... source=... -``` - -`DescribeRuntime` ne contacte pas Bitwarden par défaut. Pour vérifier réellement -la disponibilité du backend, utiliser le préflight : - -```go -report, err := secretstore.PreflightFromManifest(secretstore.PreflightOptions{ - ServiceName: "my-mcp", - LookupEnv: os.LookupEnv, -}) -if err != nil { - return err -} - -fmt.Println(report.Status) // ready | fail -fmt.Println(report.Summary) // message court -fmt.Println(report.Remediation) // action recommandée -``` - -## Debug Bitwarden en 60 secondes - -Tu peux activer les traces d'appels Bitwarden avec le flag CLI global `--debug` -(via `bootstrap`) ou en exportant `MCP_FRAMEWORK_BITWARDEN_DEBUG=1`. -Les commandes `bw` exécutées seront affichées (avec redaction des payloads sensibles). - -1. Vérifier l'état de session : - -```bash -bw status -``` - -2. Déverrouiller le vault et exporter `BW_SESSION` (ou utiliser `LoginBitwarden`) : - -- Bash/Zsh : - -```bash -export BW_SESSION="$(bw unlock --raw)" -``` - -- Fish : - -```fish -set -x BW_SESSION (bw unlock --raw) -``` - -- PowerShell : - -```powershell -$env:BW_SESSION = (bw unlock --raw) -``` - -3. Vérifier lecture/écriture rapide : - -```go -if err := store.SetSecret("debug-token", "Debug token", "ok"); err != nil { - return err -} -_, err := store.GetSecret("debug-token") -return err -``` - -4. Interpréter les erreurs typées : - -- `secretstore.ErrBWNotLoggedIn` : `bw login` requis. -- `secretstore.ErrBWLocked` : vault verrouillé ou `BW_SESSION` absent. -- `secretstore.ErrBWUnavailable` : CLI/réseau indisponible. -- `secretstore.ErrBackendUnavailable` : policy non satisfiable dans le contexte courant. - -En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`. -Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`. diff --git a/docs/superpowers/plans/2026-05-02-bitwarden-cache.md b/docs/superpowers/plans/2026-05-02-bitwarden-cache.md deleted file mode 100644 index e352fed..0000000 --- a/docs/superpowers/plans/2026-05-02-bitwarden-cache.md +++ /dev/null @@ -1,1356 +0,0 @@ -# Bitwarden Cache Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a secure, portable Bitwarden secret cache with memory-first reads and encrypted disk persistence derived from `BW_SESSION`. - -**Architecture:** The cache is internal to `secretstore` and used only by the `bitwarden-cli` backend. `GetSecret` reads memory, encrypted disk, then Bitwarden CLI; successful CLI reads fill both caches. Manifest and generated helper options carry a default-enabled `bitwarden_cache` option, with an environment override for operational disablement. - -**Tech Stack:** Go 1.25 standard library (`crypto/aes`, `crypto/cipher`, `crypto/hkdf`, `crypto/hmac`, `crypto/rand`, `crypto/sha256`, `encoding/json`, `os`, `time`), existing `manifest`, `secretstore`, and `generate` packages. - ---- - -## File Structure - -- Create `secretstore/bitwarden_cache.go`: cache implementation, key derivation, disk encryption, TTL, path resolution, env override parsing. -- Create `secretstore/bitwarden_cache_test.go`: focused cache tests independent from the Bitwarden fake CLI when possible. -- Modify `secretstore/bitwarden.go`: add cache field to `bitwardenStore`, initialize it in `newBitwardenStore`, and use it in `GetSecret`, `SetSecret`, `DeleteSecret`. -- Modify `secretstore/store.go`: add cache options and defaults to `Options`. -- Modify `secretstore/manifest_open.go`: resolve `manifest.SecretStore.BitwardenCache` into `OpenFromManifestOptions` and `Options`. -- Modify `secretstore/runtime.go`: pass cache option through runtime describe/preflight paths. -- Modify `manifest/manifest.go`: parse and normalize `[secret_store].bitwarden_cache` with tri-state semantics so omission means enabled. -- Modify `manifest/manifest_test.go`: test parsing explicit false and omitted default behavior. -- Modify `secretstore/manifest_open_test.go`: test manifest false disables Bitwarden cache. -- Modify `generate/generate.go`: expose `DisableBitwardenCache` in generated `SecretStoreOptions` and pass it to framework options. -- Modify `generate/generate_test.go`: assert generated helper contains the new option. -- Modify `docs/manifest.md` and `docs/secrets.md`: document cache default, disable controls, TTL, and threat model. - -## Manifest Option Shape - -Use a pointer bool in `manifest.SecretStore` so the code can distinguish omitted from explicit false: - -```go -type SecretStore struct { - BackendPolicy string `toml:"backend_policy"` - BitwardenCache *bool `toml:"bitwarden_cache"` -} -``` - -Use a value bool named `DisableBitwardenCache` in runtime option structs. This preserves default-enabled zero-value behavior while still allowing callers to disable the cache programmatically. The manifest resolver applies `bitwarden_cache = false`, then the runtime disable flag and environment override can force disablement. - ---- - -### Task 1: Manifest Parses `bitwarden_cache` - -**Files:** -- Modify: `manifest/manifest.go` -- Test: `manifest/manifest_test.go` - -- [ ] **Step 1: Write the failing manifest test** - -Add this to `manifest/manifest_test.go`: - -```go -func TestLoadParsesSecretStoreBitwardenCache(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, DefaultFile) - - const content = ` -[secret_store] -backend_policy = "bitwarden-cli" -bitwarden_cache = false -` - - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - - file, err := Load(path) - if err != nil { - t.Fatalf("Load returned error: %v", err) - } - if file.SecretStore.BitwardenCache == nil { - t.Fatal("bitwarden cache option is nil, want explicit false pointer") - } - if *file.SecretStore.BitwardenCache { - t.Fatal("bitwarden cache option = true, want false") - } -} - -func TestLoadLeavesOmittedBitwardenCacheUnset(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, DefaultFile) - - const content = ` -[secret_store] -backend_policy = "bitwarden-cli" -` - - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - - file, err := Load(path) - if err != nil { - t.Fatalf("Load returned error: %v", err) - } - if file.SecretStore.BitwardenCache != nil { - t.Fatalf("bitwarden cache option = %v, want nil when omitted", *file.SecretStore.BitwardenCache) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: - -```bash -go test ./manifest -run 'TestLoadParsesSecretStoreBitwardenCache|TestLoadLeavesOmittedBitwardenCacheUnset' -``` - -Expected: FAIL because `SecretStore.BitwardenCache` does not exist. - -- [ ] **Step 3: Implement manifest field** - -In `manifest/manifest.go`, change `SecretStore` to: - -```go -type SecretStore struct { - BackendPolicy string `toml:"backend_policy"` - BitwardenCache *bool `toml:"bitwarden_cache"` -} -``` - -Keep `normalize` unchanged except for preserving the pointer: - -```go -func (s *SecretStore) normalize() { - s.BackendPolicy = strings.TrimSpace(s.BackendPolicy) -} -``` - -- [ ] **Step 4: Run manifest tests** - -Run: - -```bash -go test ./manifest -run 'TestLoadParsesSecretStoreBitwardenCache|TestLoadLeavesOmittedBitwardenCacheUnset' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -Run: - -```bash -git add manifest/manifest.go manifest/manifest_test.go -git commit -m "feat: parse bitwarden cache manifest option" -``` - ---- - -### Task 2: Add Runtime Cache Option Plumbing - -**Files:** -- Modify: `secretstore/store.go` -- Modify: `secretstore/manifest_open.go` -- Modify: `secretstore/runtime.go` -- Test: `secretstore/manifest_open_test.go` - -- [ ] **Step 1: Write failing manifest plumbing test** - -Add this to `secretstore/manifest_open_test.go`: - -```go -func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) { - cacheDisabled := false - resolution, err := resolveManifestPolicy(OpenFromManifestOptions{ - ServiceName: "email-mcp", - ExecutableResolver: func() (string, error) { - return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil - }, - ManifestLoader: func(startDir string) (manifest.File, string, error) { - return manifest.File{ - SecretStore: manifest.SecretStore{ - BackendPolicy: string(BackendBitwardenCLI), - BitwardenCache: &cacheDisabled, - }, - }, filepath.Join(startDir, manifest.DefaultFile), nil - }, - }) - if err != nil { - t.Fatalf("resolveManifestPolicy returned error: %v", err) - } - if resolution.BitwardenCache { - t.Fatal("resolution BitwardenCache = true, want false") - } - if resolution.Policy != BackendBitwardenCLI { - t.Fatalf("resolution policy = %q, want %q", resolution.Policy, BackendBitwardenCLI) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: - -```bash -go test ./secretstore -run TestResolveManifestPolicyPreservesBitwardenCacheDisable -``` - -Expected: FAIL because cache option fields are not wired. - -- [ ] **Step 3: Add option fields** - -In `secretstore/store.go`, update `Options`: - -```go -type Options struct { - ServiceName string - BackendPolicy BackendPolicy - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string - BitwardenCommand string - BitwardenDebug bool - DisableBitwardenCache bool - Shell string -} -``` - -In `secretstore/manifest_open.go`, update `OpenFromManifestOptions`: - -```go -type OpenFromManifestOptions struct { - ServiceName string - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string - BitwardenCommand string - BitwardenDebug bool - DisableBitwardenCache bool - Shell string - ManifestLoader ManifestLoader - ExecutableResolver ExecutableResolver -} -``` - -Add a field to `manifestPolicyResolution`: - -```go -type manifestPolicyResolution struct { - Policy BackendPolicy - Source string - BitwardenCache bool -} -``` - -Where `resolveManifestPolicy` returns missing-manifest or omitted-field defaults, set `BitwardenCache: true`. When manifest has an explicit pointer, use it: - -```go -bitwardenCache := true -if file.SecretStore.BitwardenCache != nil { - bitwardenCache = *file.SecretStore.BitwardenCache -} -``` - -Pass it in `OpenFromManifest`: - -```go -DisableBitwardenCache: options.DisableBitwardenCache || !manifestPolicy.BitwardenCache, -``` - -This keeps `OpenFromManifestOptions{}` default-enabled when the manifest omits the field, while still respecting both `bitwarden_cache = false` and a caller's explicit disable flag. If a helper is clearer, add: - -```go -func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool { - return runtimeDisabled || !manifestEnabled -} -``` - -Call it as: - -```go -DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, manifestPolicy.BitwardenCache), -``` - -- [ ] **Step 4: Pass through runtime describe options** - -In `secretstore/runtime.go`, add `DisableBitwardenCache bool` to `DescribeRuntimeOptions`, and pass it to `Open`. - -Use: - -```go -DisableBitwardenCache: options.DisableBitwardenCache, -``` - -- [ ] **Step 5: Run plumbing test** - -Run: - -```bash -go test ./secretstore -run TestResolveManifestPolicyPreservesBitwardenCacheDisable -``` - -Expected: PASS. - -- [ ] **Step 6: Commit compile-safe plumbing** - -Run the broader compile target first: - -```bash -go test ./secretstore ./manifest -``` - -Expected: PASS. - -Then run: - -```bash -git add secretstore/store.go secretstore/manifest_open.go secretstore/runtime.go secretstore/manifest_open_test.go -git commit -m "feat: wire bitwarden cache options" -``` - ---- - -### Task 3: Implement Cache Core - -**Files:** -- Create: `secretstore/bitwarden_cache.go` -- Create: `secretstore/bitwarden_cache_test.go` - -- [ ] **Step 1: Write cache core tests** - -Create `secretstore/bitwarden_cache_test.go`: - -```go -package secretstore - -import ( - "bytes" - "os" - "strings" - "testing" - "time" -) - -func TestBitwardenCacheMemoryHit(t *testing.T) { - cache := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v1", - TTL: 10 * time.Minute, - Now: func() time.Time { return time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) }, - CacheDir: t.TempDir(), - Enabled: true, - }) - - cache.store("api-token", "email-mcp/api-token", "secret-v1") - got, ok := cache.loadMemory("api-token", "email-mcp/api-token") - if !ok { - t.Fatal("memory cache miss, want hit") - } - if got != "secret-v1" { - t.Fatalf("memory cache value = %q, want secret-v1", got) - } -} - -func TestBitwardenCacheDiskRoundTripIsEncrypted(t *testing.T) { - dir := t.TempDir() - now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) - cache := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v1", - TTL: 10 * time.Minute, - Now: func() time.Time { return now }, - CacheDir: dir, - Enabled: true, - }) - - cache.store("api-token", "email-mcp/api-token", "secret-v1") - - reopened := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v1", - TTL: 10 * time.Minute, - Now: func() time.Time { return now.Add(time.Minute) }, - CacheDir: dir, - Enabled: true, - }) - got, ok := reopened.loadDisk("api-token", "email-mcp/api-token") - if !ok { - t.Fatal("disk cache miss, want hit") - } - if got != "secret-v1" { - t.Fatalf("disk cache value = %q, want secret-v1", got) - } - - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatalf("ReadDir cache dir: %v", err) - } - if len(entries) != 1 { - t.Fatalf("cache file count = %d, want 1", len(entries)) - } - data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) - if err != nil { - t.Fatalf("ReadFile cache file: %v", err) - } - if bytes.Contains(data, []byte("secret-v1")) { - t.Fatalf("cache file contains plaintext secret: %s", data) - } - if strings.Contains(entries[0].Name(), "api-token") { - t.Fatalf("cache file name exposes secret name: %s", entries[0].Name()) - } -} - -func TestBitwardenCacheRejectsChangedSession(t *testing.T) { - dir := t.TempDir() - now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) - cache := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v1", - TTL: 10 * time.Minute, - Now: func() time.Time { return now }, - CacheDir: dir, - Enabled: true, - }) - cache.store("api-token", "email-mcp/api-token", "secret-v1") - - changed := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v2", - TTL: 10 * time.Minute, - Now: func() time.Time { return now.Add(time.Minute) }, - CacheDir: dir, - Enabled: true, - }) - if got, ok := changed.loadDisk("api-token", "email-mcp/api-token"); ok { - t.Fatalf("disk cache hit with changed session = %q, want miss", got) - } -} - -func TestBitwardenCacheExpiresEntries(t *testing.T) { - dir := t.TempDir() - now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) - cache := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v1", - TTL: time.Minute, - Now: func() time.Time { return now }, - CacheDir: dir, - Enabled: true, - }) - cache.store("api-token", "email-mcp/api-token", "secret-v1") - - expired := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v1", - TTL: time.Minute, - Now: func() time.Time { return now.Add(2 * time.Minute) }, - CacheDir: dir, - Enabled: true, - }) - if got, ok := expired.load("api-token", "email-mcp/api-token"); ok { - t.Fatalf("expired cache hit = %q, want miss", got) - } -} -``` - -If `filepath` is missing, add it to the imports. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -go test ./secretstore -run 'TestBitwardenCache' -``` - -Expected: FAIL because cache types/functions do not exist. - -- [ ] **Step 3: Implement cache core** - -Create `secretstore/bitwarden_cache.go`: - -```go -package secretstore - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/hkdf" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" -) - -const ( - bitwardenCacheEnvName = "MCP_FRAMEWORK_BITWARDEN_CACHE" - defaultBitwardenCacheTTL = 10 * time.Minute - bitwardenCacheFormatVersion = 1 - bitwardenCacheAlgorithm = "AES-256-GCM" - bitwardenCacheDirName = "bitwarden-cache" - bitwardenCacheSalt = "mcp-framework bitwarden cache salt v1" - bitwardenCacheInfo = "mcp-framework bitwarden cache v1" - bitwardenCacheEncryptionInfo = "mcp-framework bitwarden cache encryption v1" - bitwardenCacheEntryIDInfo = "mcp-framework bitwarden cache entry id v1" - bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1" -) - -type bitwardenCacheOptions struct { - ServiceName string - Session string - TTL time.Duration - Now func() time.Time - CacheDir string - Enabled bool -} - -type bitwardenCache struct { - mu sync.Mutex - enabled bool - serviceName string - ttl time.Duration - now func() time.Time - cacheDir string - encryptionKey []byte - entryIDKey []byte - memory map[string]bitwardenCacheMemoryEntry -} - -type bitwardenCacheMemoryEntry struct { - value string - expiresAt time.Time -} - -type bitwardenCachePlaintext struct { - Version int `json:"version"` - ServiceName string `json:"service_name"` - SecretName string `json:"secret_name"` - ScopedName string `json:"scoped_name"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` - Value string `json:"value"` -} - -type bitwardenCacheEnvelope struct { - Version int `json:"version"` - Algorithm string `json:"algorithm"` - Nonce string `json:"nonce"` - Ciphertext string `json:"ciphertext"` -} - -func newBitwardenCache(options bitwardenCacheOptions) *bitwardenCache { - now := options.Now - if now == nil { - now = time.Now - } - ttl := options.TTL - if ttl <= 0 { - ttl = defaultBitwardenCacheTTL - } - - cache := &bitwardenCache{ - enabled: options.Enabled, - serviceName: strings.TrimSpace(options.ServiceName), - ttl: ttl, - now: now, - cacheDir: strings.TrimSpace(options.CacheDir), - memory: map[string]bitwardenCacheMemoryEntry{}, - } - if !cache.enabled { - return cache - } - - session := strings.TrimSpace(options.Session) - if session == "" { - return cache - } - masterKey, err := hkdf.Key(sha256.New, []byte(session), []byte(bitwardenCacheSalt), bitwardenCacheInfo, 32) - if err != nil { - return cache - } - cache.encryptionKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEncryptionInfo, 32) - if err != nil { - cache.encryptionKey = nil - return cache - } - cache.entryIDKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEntryIDInfo, 32) - if err != nil { - cache.encryptionKey = nil - cache.entryIDKey = nil - return cache - } - return cache -} - -func (c *bitwardenCache) load(secretName, scopedName string) (string, bool) { - if value, ok := c.loadMemory(secretName, scopedName); ok { - return value, true - } - return c.loadDisk(secretName, scopedName) -} - -func (c *bitwardenCache) store(secretName, scopedName, value string) { - if c == nil || !c.enabled { - return - } - c.storeMemory(secretName, scopedName, value) - c.storeDisk(secretName, scopedName, value) -} - -func (c *bitwardenCache) invalidate(secretName, scopedName string) { - if c == nil { - return - } - key := c.memoryKey(secretName, scopedName) - c.mu.Lock() - delete(c.memory, key) - c.mu.Unlock() - if path, ok := c.entryPath(secretName, scopedName); ok { - _ = os.Remove(path) - } -} - -func (c *bitwardenCache) loadMemory(secretName, scopedName string) (string, bool) { - if c == nil || !c.enabled { - return "", false - } - key := c.memoryKey(secretName, scopedName) - now := c.now() - c.mu.Lock() - defer c.mu.Unlock() - entry, ok := c.memory[key] - if !ok { - return "", false - } - if !entry.expiresAt.After(now) { - delete(c.memory, key) - return "", false - } - return entry.value, true -} - -func (c *bitwardenCache) storeMemory(secretName, scopedName, value string) { - key := c.memoryKey(secretName, scopedName) - c.mu.Lock() - c.memory[key] = bitwardenCacheMemoryEntry{ - value: value, - expiresAt: c.now().Add(c.ttl), - } - c.mu.Unlock() -} - -func (c *bitwardenCache) loadDisk(secretName, scopedName string) (string, bool) { - path, ok := c.entryPath(secretName, scopedName) - if !ok { - return "", false - } - data, err := os.ReadFile(path) - if err != nil { - return "", false - } - var envelope bitwardenCacheEnvelope - if err := json.Unmarshal(data, &envelope); err != nil { - _ = os.Remove(path) - return "", false - } - plaintext, err := c.decryptEnvelope(secretName, scopedName, envelope) - if err != nil { - _ = os.Remove(path) - return "", false - } - if plaintext.Version != bitwardenCacheFormatVersion || - plaintext.ServiceName != c.serviceName || - plaintext.SecretName != strings.TrimSpace(secretName) || - plaintext.ScopedName != strings.TrimSpace(scopedName) || - !plaintext.ExpiresAt.After(c.now()) { - _ = os.Remove(path) - return "", false - } - c.storeMemory(secretName, scopedName, plaintext.Value) - return plaintext.Value, true -} - -func (c *bitwardenCache) storeDisk(secretName, scopedName, value string) { - if c.cacheDir == "" || len(c.encryptionKey) == 0 || len(c.entryIDKey) == 0 { - return - } - path, ok := c.entryPath(secretName, scopedName) - if !ok { - return - } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return - } - _ = os.Chmod(filepath.Dir(path), 0o700) - - now := c.now() - plaintext := bitwardenCachePlaintext{ - Version: bitwardenCacheFormatVersion, - ServiceName: c.serviceName, - SecretName: strings.TrimSpace(secretName), - ScopedName: strings.TrimSpace(scopedName), - CreatedAt: now, - ExpiresAt: now.Add(c.ttl), - Value: value, - } - envelope, err := c.encryptPlaintext(secretName, scopedName, plaintext) - if err != nil { - return - } - data, err := json.Marshal(envelope) - if err != nil { - return - } - tmp, err := os.CreateTemp(filepath.Dir(path), "bitwarden-cache-*.tmp") - if err != nil { - return - } - tmpPath := tmp.Name() - cleanup := true - defer func() { - _ = tmp.Close() - if cleanup { - _ = os.Remove(tmpPath) - } - }() - _ = tmp.Chmod(0o600) - if _, err := tmp.Write(data); err != nil { - return - } - if err := tmp.Close(); err != nil { - return - } - if err := os.Rename(tmpPath, path); err != nil { - return - } - _ = os.Chmod(path, 0o600) - cleanup = false -} - -func (c *bitwardenCache) encryptPlaintext(secretName, scopedName string, plaintext bitwardenCachePlaintext) (bitwardenCacheEnvelope, error) { - raw, err := json.Marshal(plaintext) - if err != nil { - return bitwardenCacheEnvelope{}, err - } - block, err := aes.NewCipher(c.encryptionKey) - if err != nil { - return bitwardenCacheEnvelope{}, err - } - aead, err := cipher.NewGCM(block) - if err != nil { - return bitwardenCacheEnvelope{}, err - } - nonce := make([]byte, aead.NonceSize()) - if _, err := rand.Read(nonce); err != nil { - return bitwardenCacheEnvelope{}, err - } - ciphertext := aead.Seal(nil, nonce, raw, c.additionalData(secretName, scopedName)) - return bitwardenCacheEnvelope{ - Version: bitwardenCacheFormatVersion, - Algorithm: bitwardenCacheAlgorithm, - Nonce: base64.StdEncoding.EncodeToString(nonce), - Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), - }, nil -} - -func (c *bitwardenCache) decryptEnvelope(secretName, scopedName string, envelope bitwardenCacheEnvelope) (bitwardenCachePlaintext, error) { - if envelope.Version != bitwardenCacheFormatVersion || envelope.Algorithm != bitwardenCacheAlgorithm { - return bitwardenCachePlaintext{}, errors.New("unsupported bitwarden cache envelope") - } - nonce, err := base64.StdEncoding.DecodeString(envelope.Nonce) - if err != nil { - return bitwardenCachePlaintext{}, err - } - ciphertext, err := base64.StdEncoding.DecodeString(envelope.Ciphertext) - if err != nil { - return bitwardenCachePlaintext{}, err - } - block, err := aes.NewCipher(c.encryptionKey) - if err != nil { - return bitwardenCachePlaintext{}, err - } - aead, err := cipher.NewGCM(block) - if err != nil { - return bitwardenCachePlaintext{}, err - } - raw, err := aead.Open(nil, nonce, ciphertext, c.additionalData(secretName, scopedName)) - if err != nil { - return bitwardenCachePlaintext{}, err - } - var plaintext bitwardenCachePlaintext - if err := json.Unmarshal(raw, &plaintext); err != nil { - return bitwardenCachePlaintext{}, err - } - return plaintext, nil -} - -func (c *bitwardenCache) entryPath(secretName, scopedName string) (string, bool) { - if c == nil || !c.enabled || c.cacheDir == "" || len(c.entryIDKey) == 0 { - return "", false - } - mac := hmac.New(sha256.New, c.entryIDKey) - _, _ = mac.Write([]byte(c.cacheContext(secretName, scopedName))) - return filepath.Join(c.cacheDir, hex.EncodeToString(mac.Sum(nil))+".json"), true -} - -func (c *bitwardenCache) memoryKey(secretName, scopedName string) string { - return c.cacheContext(secretName, scopedName) -} - -func (c *bitwardenCache) additionalData(secretName, scopedName string) []byte { - return []byte(fmt.Sprintf( - "mcp-framework bitwarden cache v1\nservice=%s\nsecret=%s\nscoped=%s", - c.serviceName, - strings.TrimSpace(secretName), - strings.TrimSpace(scopedName), - )) -} - -func (c *bitwardenCache) cacheContext(secretName, scopedName string) string { - return fmt.Sprintf( - "version=%d\nservice=%s\nsecret=%s\nscoped=%s\nscope=%s", - bitwardenCacheFormatVersion, - c.serviceName, - strings.TrimSpace(secretName), - strings.TrimSpace(scopedName), - bitwardenCacheContextScope, - ) -} - -func resolveBitwardenCacheDir(serviceName string) string { - cacheRoot, err := os.UserCacheDir() - if err != nil { - return "" - } - return filepath.Join(cacheRoot, strings.TrimSpace(serviceName), bitwardenCacheDirName) -} - -func bitwardenCacheDisabledByEnv() bool { - raw, ok := os.LookupEnv(bitwardenCacheEnvName) - if !ok { - return false - } - switch strings.ToLower(strings.TrimSpace(raw)) { - case "0", "false", "no", "off", "disabled": - return true - default: - return false - } -} -``` - -- [ ] **Step 4: Fix test imports** - -Ensure `secretstore/bitwarden_cache_test.go` imports `path/filepath` because `TestBitwardenCacheDiskRoundTripIsEncrypted` uses it. - -- [ ] **Step 5: Run cache core tests** - -Run: - -```bash -go test ./secretstore -run 'TestBitwardenCache' -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -Run: - -```bash -git add secretstore/bitwarden_cache.go secretstore/bitwarden_cache_test.go -git commit -m "feat: add encrypted bitwarden cache core" -``` - ---- - -### Task 4: Integrate Cache With Bitwarden Store - -**Files:** -- Modify: `secretstore/bitwarden.go` -- Modify: `secretstore/bitwarden_test.go` -- Modify: `secretstore/manifest_open_test.go` - -- [ ] **Step 1: Add integration tests** - -Add these tests to `secretstore/bitwarden_test.go`: - -```go -func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ - ID: "item-1", - Name: "email-mcp/api-token", - Secret: "secret-v1", - MarkerService: "email-mcp", - MarkerSecretName: "api-token", - } - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - for i := 0; i < 2; i++ { - value, err := store.GetSecret("api-token") - if err != nil { - t.Fatalf("GetSecret #%d returned error: %v", i+1, err) - } - if value != "secret-v1" { - t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) - } - } - if fakeCLI.getItemCalls != 1 { - t.Fatalf("bw get item count = %d, want 1 with memory cache", fakeCLI.getItemCalls) - } -} - -func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) { - withBitwardenSession(t) - t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0") - fakeCLI := newFakeBitwardenCLI("bw") - fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ - ID: "item-1", - Name: "email-mcp/api-token", - Secret: "secret-v1", - MarkerService: "email-mcp", - MarkerSecretName: "api-token", - } - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - for i := 0; i < 2; i++ { - if _, err := store.GetSecret("api-token"); err != nil { - t.Fatalf("GetSecret #%d returned error: %v", i+1, err) - } - } - if fakeCLI.getItemCalls != 2 { - t.Fatalf("bw get item count = %d, want 2 when env disables cache", fakeCLI.getItemCalls) - } -} -``` - -Add this test to `secretstore/manifest_open_test.go`: - -```go -func TestOpenFromManifestAppliesBitwardenCacheDisable(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ - ID: "item-1", - Name: "email-mcp/api-token", - Secret: "secret-v1", - MarkerService: "email-mcp", - MarkerSecretName: "api-token", - } - withBitwardenRunner(t, fakeCLI.run) - - cacheDisabled := false - store, err := OpenFromManifest(OpenFromManifestOptions{ - ServiceName: "email-mcp", - ExecutableResolver: func() (string, error) { - return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil - }, - ManifestLoader: func(startDir string) (manifest.File, string, error) { - return manifest.File{ - SecretStore: manifest.SecretStore{ - BackendPolicy: string(BackendBitwardenCLI), - BitwardenCache: &cacheDisabled, - }, - }, filepath.Join(startDir, manifest.DefaultFile), nil - }, - }) - if err != nil { - t.Fatalf("OpenFromManifest returned error: %v", err) - } - - for i := 0; i < 2; i++ { - value, err := store.GetSecret("api-token") - if err != nil { - t.Fatalf("GetSecret #%d returned error: %v", i+1, err) - } - if value != "secret-v1" { - t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) - } - } - if fakeCLI.getItemCalls != 2 { - t.Fatalf("bw get item count = %d, want 2 when manifest disables cache", fakeCLI.getItemCalls) - } -} -``` - -- [ ] **Step 2: Run integration tests to verify failure** - -Run: - -```bash -go test ./secretstore -run 'TestBitwardenStoreGetSecretUsesMemoryCache|TestBitwardenStoreCacheDisabledByEnv|TestOpenFromManifestAppliesBitwardenCacheDisable' -``` - -Expected: FAIL until `bitwardenStore` uses the cache. - -- [ ] **Step 3: Add cache field and initialization** - -In `secretstore/bitwarden.go`, update `bitwardenStore`: - -```go -type bitwardenStore struct { - command string - serviceName string - debug bool - cache *bitwardenCache -} -``` - -In `newBitwardenStore`, after `EnsureBitwardenSessionEnv`, read the effective session: - -```go -session, _ := os.LookupEnv(bitwardenSessionEnvName) -cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv() -``` - -When constructing the store: - -```go -store := &bitwardenStore{ - command: command, - serviceName: serviceName, - debug: debugEnabled, - cache: newBitwardenCache(bitwardenCacheOptions{ - ServiceName: serviceName, - Session: session, - TTL: defaultBitwardenCacheTTL, - CacheDir: resolveBitwardenCacheDir(serviceName), - Enabled: cacheEnabled, - }), -} -``` - -Make sure this happens after session restoration, not before. If needed, move the `store := &bitwardenStore{...}` block below the `EnsureBitwardenSessionEnv` call. - -- [ ] **Step 4: Use cache in `GetSecret`** - -Change `GetSecret`: - -```go -func (s *bitwardenStore) GetSecret(name string) (string, error) { - secretName := s.scopedName(name) - if s.cache != nil { - if secret, ok := s.cache.load(name, secretName); ok { - return secret, nil - } - } - - _, payload, err := s.findItem(secretName, name) - if err != nil { - return "", err - } - - secret, ok := readBitwardenSecret(payload) - if !ok { - return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName) - } - - if s.cache != nil { - s.cache.store(name, secretName, secret) - } - return secret, nil -} -``` - -- [ ] **Step 5: Invalidate on writes and deletes** - -After successful create/edit in `SetSecret`, call: - -```go -if s.cache != nil { - s.cache.invalidate(name, secretName) - s.cache.store(name, secretName, secret) -} -``` - -After successful delete or not-found in `DeleteSecret`, call: - -```go -if s.cache != nil { - s.cache.invalidate(name, secretName) -} -``` - -- [ ] **Step 6: Run integration tests** - -Run: - -```bash -go test ./secretstore -run 'TestBitwardenStoreGetSecretUsesMemoryCache|TestBitwardenStoreCacheDisabledByEnv|TestOpenFromManifestAppliesBitwardenCacheDisable' -``` - -Expected: PASS. - -- [ ] **Step 7: Run all secretstore tests** - -Run: - -```bash -go test ./secretstore -``` - -Expected: PASS. - -- [ ] **Step 8: Commit** - -Run: - -```bash -git add secretstore/bitwarden.go secretstore/bitwarden_test.go secretstore/manifest_open_test.go -git commit -m "feat: cache bitwarden secret reads" -``` - ---- - -### Task 5: Generated Helper Support - -**Files:** -- Modify: `generate/generate.go` -- Modify: `generate/generate_test.go` - -- [ ] **Step 1: Write failing generated helper assertion** - -In `generate/generate_test.go`, add these expected snippets to the test that checks generated secret store helper content: - -```go -"DisableBitwardenCache bool", -"DisableBitwardenCache: options.DisableBitwardenCache,", -``` - -If there is no focused assertion list, add a test: - -```go -func TestGenerateSecretStoreIncludesBitwardenCacheOption(t *testing.T) { - projectDir := newProject(t, ` -binary_name = "demo-mcp" - -[secret_store] -backend_policy = "bitwarden-cli" -`) - - if _, err := Generate(Options{ProjectDir: projectDir}); err != nil { - t.Fatalf("Generate returned error: %v", err) - } - - content, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go")) - if err != nil { - t.Fatalf("ReadFile generated secretstore: %v", err) - } - text := string(content) - for _, snippet := range []string{ - "DisableBitwardenCache bool", - "DisableBitwardenCache: options.DisableBitwardenCache,", - } { - if !strings.Contains(text, snippet) { - t.Fatalf("generated secretstore.go missing %q:\n%s", snippet, text) - } - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: - -```bash -go test ./generate -run TestGenerateSecretStoreIncludesBitwardenCacheOption -``` - -Expected: FAIL because generated code lacks `DisableBitwardenCache`. - -- [ ] **Step 3: Update generator template** - -In `generate/generate.go`, add to generated `SecretStoreOptions`: - -```go -DisableBitwardenCache bool -``` - -Pass to `OpenFromManifestOptions` and `DescribeRuntimeOptions`: - -```go -DisableBitwardenCache: options.DisableBitwardenCache, -``` - -- [ ] **Step 4: Run generator test** - -Run: - -```bash -go test ./generate -run TestGenerateSecretStoreIncludesBitwardenCacheOption -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -Run: - -```bash -git add generate/generate.go generate/generate_test.go -git commit -m "feat: expose bitwarden cache in generated helpers" -``` - ---- - -### Task 6: Documentation - -**Files:** -- Modify: `docs/manifest.md` -- Modify: `docs/secrets.md` - -- [ ] **Step 1: Update manifest docs** - -In `docs/manifest.md`, update the `[secret_store]` example: - -```toml -[secret_store] -backend_policy = "auto" -# Optionnel: mettre false pour désactiver le cache Bitwarden. -bitwarden_cache = true -``` - -Add field description: - -```markdown -- `[secret_store].bitwarden_cache` : active le cache Bitwarden mémoire + disque chiffré quand `backend_policy = "bitwarden-cli"`. Par défaut, le cache est activé si le champ est absent. Mettre `false` pour le désactiver. -``` - -- [ ] **Step 2: Update secrets docs** - -In `docs/secrets.md`, after the Bitwarden backend section, add this Markdown: - -````markdown -## Cache Bitwarden - -Le backend `bitwarden-cli` met en cache les lectures de secrets par défaut. -Le cache mémoire évite les appels répétés au CLI dans un même process. Le cache -disque est chiffré avec une clé dérivée de `BW_SESSION` via HKDF-SHA256 et AES-GCM. - -TTL par défaut : 10 minutes. - -Pour désactiver le cache dans `mcp.toml` : - -```toml -[secret_store] -backend_policy = "bitwarden-cli" -bitwarden_cache = false -``` - -Pour le désactiver sans modifier le manifeste : - -```bash -MCP_FRAMEWORK_BITWARDEN_CACHE=0 -``` - -Le fichier de cache et le binaire installé ne suffisent pas à déchiffrer les -secrets. Si `BW_SESSION` change ou disparaît, les entrées disque existantes -deviennent inutilisables. Cette protection ne couvre pas un attaquant qui peut -lire l'environnement ou la mémoire du process pendant l'exécution. -```` - -- [ ] **Step 3: Run docs grep** - -Run: - -```bash -rg -n "bitwarden_cache|MCP_FRAMEWORK_BITWARDEN_CACHE|Cache Bitwarden" docs -``` - -Expected: shows entries in `docs/manifest.md` and `docs/secrets.md`. - -- [ ] **Step 4: Commit** - -Run: - -```bash -git add docs/manifest.md docs/secrets.md -git commit -m "docs: document bitwarden cache controls" -``` - ---- - -### Task 7: Final Validation - -**Files:** -- No planned code edits unless validation exposes a defect. - -- [ ] **Step 1: Run targeted package tests** - -Run: - -```bash -go test ./manifest ./secretstore ./generate -``` - -Expected: PASS. - -- [ ] **Step 2: Run full test suite** - -Run: - -```bash -go test ./... -``` - -Expected: PASS. - -- [ ] **Step 3: Inspect git status** - -Run: - -```bash -git status --short -``` - -Expected: clean working tree. - -- [ ] **Step 4: If validation changed files, commit fixes** - -If any fixes were needed: - -```bash -git add -git commit -m "fix: stabilize bitwarden cache" -``` - -If no fixes were needed, do not create an empty commit. - ---- - -## Self-Review Notes - -- Spec coverage: manifest default and disable path are covered by Tasks 1, 2, and 6; secure cache core is covered by Task 3; store integration and invalidation are covered by Task 4; generated helper support is covered by Task 5; docs are covered by Task 6; validation is covered by Task 7. -- Red-flag scan: no incomplete markers or open-ended “add tests” steps remain. -- Type consistency: `BitwardenCache` is the manifest field, `DisableBitwardenCache` is the runtime/generated option, `bitwardenCache` is the internal cache type, and `bitwardenCacheEnvName` is the env constant. diff --git a/docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md b/docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md deleted file mode 100644 index 2459467..0000000 --- a/docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md +++ /dev/null @@ -1,217 +0,0 @@ -# Bitwarden Cache Design - -Date: 2026-05-02 - -## Context - -The `secretstore` package supports a `bitwarden-cli` backend. Each secret read can call the Bitwarden CLI several times: - -- `bw list items --search /` -- `bw get item ` for each candidate item - -These calls can take several seconds when commands such as MCP `setup`, `doctor`, or generated config resolution read multiple secrets or are run repeatedly. - -The framework already requires an unlocked Bitwarden session for this backend and restores a persisted `BW_SESSION` into the process environment when available. The cache must use that runtime session as the trust root without embedding a static key in the binary or repository. - -## Goals - -- Avoid repeated Bitwarden CLI calls for short-lived and long-running framework processes. -- Keep the feature portable across Windows, macOS, Linux, and WSL. -- Enable the encrypted disk cache by default when `BW_SESSION` is available. -- Allow projects and operators to disable the cache. -- Never make cached secrets decryptable with only the installed binary, cache files, and repository content. - -## Non-Goals - -- Protect against an attacker who can read the process memory or environment while the process is running. -- Add an OS keyring dependency for the first implementation. -- Cache non-Bitwarden secret backends. -- Persist decrypted secrets on disk. - -## Configuration - -`manifest.SecretStore` gains: - -```toml -[secret_store] -backend_policy = "bitwarden-cli" -bitwarden_cache = true -``` - -`bitwarden_cache` defaults to `true` when omitted. - -The generated helper options continue to flow through `OpenFromManifest` and `DescribeRuntime`. The runtime option is represented in `secretstore.Options` and `secretstore.OpenFromManifestOptions` so generated packages can still override it programmatically if needed. - -An environment variable can force-disable the cache without editing `mcp.toml`: - -```text -MCP_FRAMEWORK_BITWARDEN_CACHE=0 -``` - -Accepted false values are `0`, `false`, `no`, `off`, and `disabled`, case-insensitive. Any other value leaves the manifest/default behavior in place. - -## Architecture - -The cache is internal to the `bitwarden-cli` backend. - -`bitwardenStore.GetSecret(name)` resolves secrets in this order: - -1. In-memory cache. -2. Encrypted disk cache, only if a cache key can be derived from the effective `BW_SESSION`. -3. Bitwarden CLI lookup. - -After a successful CLI lookup, the secret is written to the memory cache and, when available, the encrypted disk cache. - -`SetSecret` and `DeleteSecret` update Bitwarden first. After success, they invalidate the cache entry for the affected secret. `SetSecret` may repopulate the memory and disk cache with the new value after the Bitwarden write succeeds, but it must not expose a value that failed to persist to Bitwarden. - -## Cache Contents - -Memory entries store: - -- secret value -- expiration timestamp - -Disk entries store encrypted JSON: - -```json -{ - "version": 1, - "service_name": "graylog-mcp", - "secret_name": "profile/prod/api-token", - "scoped_name": "graylog-mcp/profile/prod/api-token", - "created_at": "2026-05-02T10:00:00Z", - "expires_at": "2026-05-02T10:10:00Z", - "value": "secret" -} -``` - -The plaintext exists only before encryption and after decryption in memory. - -## Key Derivation - -Disk cache is enabled only when the effective `BW_SESSION` is non-empty. The effective session is the value available after `EnsureBitwardenSessionEnv`, so it can come from either the process environment or the framework-restored session file. - -The cache derives dedicated keys with HKDF-SHA256: - -```text -master_key = HKDF-SHA256( - input key material: BW_SESSION, - salt: "mcp-framework bitwarden cache salt v1", - info: "mcp-framework bitwarden cache v1" -) -``` - -Separate subkeys are derived from `master_key`: - -- encryption key: `info = "mcp-framework bitwarden cache encryption v1"` -- entry ID key: `info = "mcp-framework bitwarden cache entry id v1"` - -`BW_SESSION` is never used directly as an AES key. - -## Disk Encryption - -Disk entries use AES-256-GCM from the Go standard library. - -Each write generates a fresh random nonce with `crypto/rand`. The file format is JSON with metadata needed to decrypt: - -```json -{ - "version": 1, - "algorithm": "AES-256-GCM", - "nonce": "", - "ciphertext": "" -} -``` - -Authenticated additional data includes stable, non-secret cache context: - -```text -mcp-framework bitwarden cache v1 -service= -secret= -scoped=/ -``` - -If decryption fails, the entry is treated as a cache miss. The framework may remove the unusable file as best effort. - -## Entry Identity - -Disk file names do not expose secret names or Bitwarden item refs. The file name is: - -```text -hex(HMAC-SHA256(entry_id_key, cache_context)) + ".json" -``` - -The cache context includes: - -- cache format version -- service name -- raw secret name -- scoped Bitwarden item name -- backend scope marker - -Because the HMAC key is derived from `BW_SESSION`, changing or losing `BW_SESSION` makes existing file names undiscoverable and existing entries undecryptable. - -## TTL and Invalidation - -Default TTL: 10 minutes. - -TTL applies to both memory and disk entries. Expired entries are treated as misses and may be deleted best-effort. - -The first implementation uses a constant default TTL. It keeps room for a future `bitwarden_cache_ttl` option, but does not add that option now to keep scope tight. - -Invalidation rules: - -- `SetSecret` invalidates the affected entry after the Bitwarden write succeeds. -- `DeleteSecret` invalidates the affected entry after the Bitwarden delete succeeds or confirms the item is already absent. -- `BW_SESSION` change implicitly invalidates disk cache through key derivation. -- `bitwarden_cache = false` disables both memory and disk cache for the backend instance. -- `MCP_FRAMEWORK_BITWARDEN_CACHE=0` disables both memory and disk cache. - -## Storage Location and Permissions - -Disk cache path: - -```text -os.UserCacheDir()/serviceName/bitwarden-cache -``` - -If `os.UserCacheDir` fails, disk cache is disabled and secret reads continue through memory cache and Bitwarden CLI. - -The cache directory is created with `0700` and cache files with `0600` where the platform supports Unix-style permissions. Permission setting errors disable disk cache for that operation rather than failing secret resolution, because Bitwarden remains the source of truth. - -## Error Handling - -Cache failures must not make a healthy Bitwarden backend unusable. - -- Memory cache errors are not expected. -- Disk cache read/decrypt/parse errors are treated as misses. -- Disk cache write errors are ignored after optional debug logging. -- Bitwarden CLI errors keep their current behavior and typed error classification. -- Malformed or expired cache entries are never returned. - -## Tests - -Unit tests should cover: - -- repeated `GetSecret` hits memory and avoids a second CLI item read -- reopened store can read from encrypted disk cache without calling `bw get item` -- disk cache file does not contain the secret value or clear secret name -- cache is disabled by `bitwarden_cache = false` -- cache is disabled by `MCP_FRAMEWORK_BITWARDEN_CACHE=0` -- expired entries are missed and refreshed from Bitwarden -- `SetSecret` invalidates or refreshes stale cache data -- `DeleteSecret` removes cached data -- missing `BW_SESSION` disables disk cache -- changed `BW_SESSION` cannot decrypt a previous disk entry -- manifest parsing preserves the default enabled behavior when the field is omitted - -## Documentation - -Update: - -- `docs/manifest.md` for `[secret_store].bitwarden_cache` -- `docs/secrets.md` for cache behavior, TTL, disable controls, and threat model -- generated/scaffolded `mcp.toml` examples only if the option should be visible by default - -The preferred scaffold output should omit `bitwarden_cache` because the default is enabled. Documentation should show it as the explicit disable knob. diff --git a/generate/generate.go b/generate/generate.go deleted file mode 100644 index 6128964..0000000 --- a/generate/generate.go +++ /dev/null @@ -1,617 +0,0 @@ -package generate - -import ( - "bytes" - "errors" - "fmt" - "go/format" - "go/token" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - - "gitea.lclr.dev/AI/mcp-framework/manifest" -) - -var ErrGeneratedFilesOutdated = errors.New("generated files are not up to date") - -type Options struct { - ProjectDir string - ManifestPath string - PackageDir string - PackageName string - Check bool -} - -type Result struct { - Root string - Files []string -} - -func Generate(options Options) (Result, error) { - normalized, err := normalizeOptions(options) - if err != nil { - return Result{}, err - } - - manifestFile, err := manifest.Load(normalized.ManifestPath) - if err != nil { - return Result{}, err - } - - manifestContent, err := os.ReadFile(normalized.ManifestPath) - if err != nil { - return Result{}, fmt.Errorf("read manifest %s: %w", normalized.ManifestPath, err) - } - - manifestLoader, err := renderManifestLoader(normalized.PackageName, string(manifestContent)) - if err != nil { - return Result{}, err - } - metadata, err := renderMetadata(normalized.PackageName, manifestFile) - if err != nil { - return Result{}, err - } - update, err := renderUpdate(normalized.PackageName) - if err != nil { - return Result{}, err - } - secretstore, err := renderSecretStore(normalized.PackageName) - if err != nil { - return Result{}, err - } - config, err := renderConfig(normalized.PackageName, manifestFile.Config.Fields) - if err != nil { - return Result{}, err - } - - files := []generatedFile{ - { - Path: filepath.Join(normalized.PackageDir, "manifest.go"), - Content: manifestLoader, - Mode: 0o644, - }, - { - Path: filepath.Join(normalized.PackageDir, "metadata.go"), - Content: metadata, - Mode: 0o644, - }, - { - Path: filepath.Join(normalized.PackageDir, "update.go"), - Content: update, - Mode: 0o644, - }, - { - Path: filepath.Join(normalized.PackageDir, "secretstore.go"), - Content: secretstore, - Mode: 0o644, - }, - } - if strings.TrimSpace(config) != "" { - files = append(files, generatedFile{ - Path: filepath.Join(normalized.PackageDir, "config.go"), - Content: config, - Mode: 0o644, - }) - } - - written := make([]string, 0, len(files)) - for _, file := range files { - target := filepath.Join(normalized.ProjectDir, file.Path) - if normalized.Check { - current, err := os.ReadFile(target) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path) - } - return Result{}, fmt.Errorf("read generated file %q: %w", target, err) - } - if !bytes.Equal(current, []byte(file.Content)) { - return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path) - } - written = append(written, file.Path) - continue - } - - if err := writeGeneratedFile(target, file.Content, file.Mode); err != nil { - return Result{}, err - } - written = append(written, file.Path) - } - - sort.Strings(written) - return Result{ - Root: normalized.ProjectDir, - Files: written, - }, nil -} - -type normalizedOptions struct { - ProjectDir string - ManifestPath string - PackageDir string - PackageName string - Check bool -} - -type generatedFile struct { - Path string - Content string - Mode os.FileMode -} - -func normalizeOptions(options Options) (normalizedOptions, error) { - manifestPath := strings.TrimSpace(options.ManifestPath) - projectDir := strings.TrimSpace(options.ProjectDir) - - if manifestPath == "" { - baseDir := projectDir - if baseDir == "" { - wd, err := os.Getwd() - if err != nil { - return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err) - } - baseDir = wd - } - manifestPath = filepath.Join(baseDir, manifest.DefaultFile) - } else if !filepath.IsAbs(manifestPath) { - baseDir := projectDir - if baseDir == "" { - wd, err := os.Getwd() - if err != nil { - return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err) - } - baseDir = wd - } - manifestPath = filepath.Join(baseDir, manifestPath) - } - - resolvedManifest, err := filepath.Abs(manifestPath) - if err != nil { - return normalizedOptions{}, fmt.Errorf("resolve manifest path %q: %w", manifestPath, err) - } - - if projectDir == "" { - projectDir = filepath.Dir(resolvedManifest) - } - resolvedProjectDir, err := filepath.Abs(projectDir) - if err != nil { - return normalizedOptions{}, fmt.Errorf("resolve project dir %q: %w", projectDir, err) - } - - packageDir := filepath.Clean(strings.TrimSpace(options.PackageDir)) - if packageDir == "." || packageDir == "" { - packageDir = "mcpgen" - } - if filepath.IsAbs(packageDir) || packageDir == ".." || strings.HasPrefix(packageDir, ".."+string(filepath.Separator)) { - return normalizedOptions{}, fmt.Errorf("package dir %q must be relative to the project", options.PackageDir) - } - - packageName := strings.TrimSpace(options.PackageName) - if packageName == "" { - packageName = filepath.Base(packageDir) - } - if !token.IsIdentifier(packageName) { - return normalizedOptions{}, fmt.Errorf("package name %q is not a valid Go identifier", packageName) - } - - return normalizedOptions{ - ProjectDir: resolvedProjectDir, - ManifestPath: resolvedManifest, - PackageDir: packageDir, - PackageName: packageName, - Check: options.Check, - }, nil -} - -func renderManifestLoader(packageName, manifestContent string) (string, error) { - source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. - -package %s - -import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" - -const embeddedManifest = %s - -func LoadManifest(startDir string) (fwmanifest.File, string, error) { - return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest) -} -`, packageName, strconv.Quote(manifestContent)) - - formatted, err := format.Source([]byte(source)) - if err != nil { - return "", fmt.Errorf("format generated manifest loader: %w", err) - } - - return string(formatted), nil -} - -func renderMetadata(packageName string, manifestFile manifest.File) (string, error) { - bootstrapInfo := manifestFile.BootstrapInfo() - - source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. - -package %s - -import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" - -const BinaryName = %s -const DefaultDescription = %s -const DocsURL = %s - -func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) { - manifestFile, source, err := LoadManifest(startDir) - if err != nil { - return fwmanifest.BootstrapMetadata{}, "", err - } - - return manifestFile.BootstrapInfo(), source, nil -} - -func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) { - manifestFile, source, err := LoadManifest(startDir) - if err != nil { - return fwmanifest.ScaffoldMetadata{}, "", err - } - - return manifestFile.ScaffoldInfo(), source, nil -} -`, packageName, strconv.Quote(manifestFile.BinaryName), strconv.Quote(bootstrapInfo.Description), strconv.Quote(manifestFile.DocsURL)) - - return formatGenerated("metadata", source) -} - -func renderUpdate(packageName string) (string, error) { - source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. - -package %s - -import ( - "context" - "flag" - "fmt" - "io" - "strings" - - fwupdate "gitea.lclr.dev/AI/mcp-framework/update" -) - -func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) { - return UpdateOptionsFrom(".", version, stdout) -} - -func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) { - manifestFile, _, err := LoadManifest(startDir) - if err != nil { - return fwupdate.Options{}, err - } - - binaryName := strings.TrimSpace(manifestFile.BinaryName) - if binaryName == "" { - binaryName = BinaryName - } - - return fwupdate.Options{ - CurrentVersion: version, - Stdout: stdout, - BinaryName: binaryName, - ReleaseSource: manifestFile.Update.ReleaseSource(), - }, nil -} - -func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error { - return RunUpdateFrom(ctx, args, ".", version, stdout) -} - -func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error { - fs := flag.NewFlagSet("update", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - if err := fs.Parse(args); err != nil { - return err - } - if fs.NArg() != 0 { - return fmt.Errorf("update does not accept positional arguments: %%s", strings.Join(fs.Args(), ", ")) - } - - options, err := UpdateOptionsFrom(startDir, version, stdout) - if err != nil { - return err - } - - return fwupdate.Run(ctx, options) -} -`, packageName) - - return formatGenerated("update", source) -} - -func renderSecretStore(packageName string) (string, error) { - source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. - -package %s - -import ( - "os" - "path/filepath" - "strings" - - fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" -) - -type SecretStoreOptions struct { - ServiceName string - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string - BitwardenCommand string - BitwardenDebug bool - DisableBitwardenCache bool - Shell string - ExecutableResolver fwsecretstore.ExecutableResolver -} - -func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) { - return fwsecretstore.OpenFromManifest(secretStoreOpenOptions(options)) -} - -func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) { - return fwsecretstore.DescribeRuntime(secretStoreDescribeOptions(options)) -} - -func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) { - return fwsecretstore.PreflightFromManifest(secretStoreDescribeOptions(options)) -} - -func secretStoreOpenOptions(options SecretStoreOptions) fwsecretstore.OpenFromManifestOptions { - return fwsecretstore.OpenFromManifestOptions{ - ServiceName: secretStoreServiceName(options), - LookupEnv: options.LookupEnv, - KWalletAppID: options.KWalletAppID, - KWalletFolder: options.KWalletFolder, - BitwardenCommand: options.BitwardenCommand, - BitwardenDebug: options.BitwardenDebug, - DisableBitwardenCache: options.DisableBitwardenCache, - Shell: options.Shell, - ManifestLoader: LoadManifest, - ExecutableResolver: options.ExecutableResolver, - } -} - -func secretStoreDescribeOptions(options SecretStoreOptions) fwsecretstore.DescribeRuntimeOptions { - return fwsecretstore.DescribeRuntimeOptions{ - ServiceName: secretStoreServiceName(options), - LookupEnv: options.LookupEnv, - KWalletAppID: options.KWalletAppID, - KWalletFolder: options.KWalletFolder, - BitwardenCommand: options.BitwardenCommand, - BitwardenDebug: options.BitwardenDebug, - DisableBitwardenCache: options.DisableBitwardenCache, - Shell: options.Shell, - ManifestLoader: LoadManifest, - ExecutableResolver: options.ExecutableResolver, - } -} - -func secretStoreServiceName(options SecretStoreOptions) string { - serviceName := strings.TrimSpace(options.ServiceName) - if serviceName != "" { - return serviceName - } - - startDir := "." - executableResolver := options.ExecutableResolver - if executableResolver == nil { - executableResolver = os.Executable - } - if executablePath, err := executableResolver(); err == nil { - if dir := strings.TrimSpace(filepath.Dir(strings.TrimSpace(executablePath))); dir != "" { - startDir = dir - } - } - - if manifestFile, _, err := LoadManifest(startDir); err == nil { - if binaryName := strings.TrimSpace(manifestFile.BinaryName); binaryName != "" { - return binaryName - } - } - - return BinaryName -} -`, packageName) - - return formatGenerated("secretstore", source) -} - -func renderConfig(packageName string, fields []manifest.ConfigField) (string, error) { - if len(fields) == 0 { - return "", nil - } - - var flagsBuilder strings.Builder - var specsBuilder strings.Builder - var setupBuilder strings.Builder - for _, field := range fields { - name := strings.TrimSpace(field.Name) - if name == "" { - return "", fmt.Errorf("generate config field: name must not be empty") - } - - flagName := strings.TrimSpace(field.Flag) - if flagName != "" { - fmt.Fprintf( - &flagsBuilder, - "\tflags.values[%s] = fs.String(%s, \"\", %s)\n", - strconv.Quote(name), - strconv.Quote(flagName), - strconv.Quote(configFieldLabel(field)), - ) - } - - fmt.Fprintf( - &specsBuilder, - "\t\t{Name: %s, Required: %t, DefaultValue: %s, Sources: []fwcli.ValueSource{%s}, FlagKey: %s, EnvKey: %s, ConfigKey: %s, SecretKey: replaceProfile(%s, profile)},\n", - strconv.Quote(name), - field.Required, - strconv.Quote(field.Default), - configSourceList(field.Sources), - strconv.Quote(flagName), - strconv.Quote(field.Env), - strconv.Quote(field.ConfigKey), - strconv.Quote(field.SecretKeyTemplate), - ) - - fmt.Fprintf( - &setupBuilder, - "\t\t{Name: %s, Label: %s, Type: %s, Required: %t, Default: %s, ExistingSecret: existing[%s]},\n", - strconv.Quote(name), - strconv.Quote(configFieldLabel(field)), - configSetupFieldType(field.Type), - field.Required, - strconv.Quote(field.Default), - strconv.Quote(name), - ) - } - - source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. - -package %s - -import ( - "flag" - "strings" - - fwcli "gitea.lclr.dev/AI/mcp-framework/cli" -) - -type ConfigFlags struct { - values map[string]*string -} - -func AddConfigFlags(fs *flag.FlagSet) ConfigFlags { - if fs == nil { - fs = flag.CommandLine - } - - flags := ConfigFlags{ - values: make(map[string]*string), - } -%s - return flags -} - -func ConfigFlagValues(flags ConfigFlags) map[string]string { - values := make(map[string]string) - for name, value := range flags.values { - if value == nil { - continue - } - if trimmed := strings.TrimSpace(*value); trimmed != "" { - values[name] = trimmed - } - } - return values -} - -func ResolveFieldSpecs(profile string) []fwcli.FieldSpec { - return []fwcli.FieldSpec{ -%s - } -} - -func SetupFields(existing map[string]string) []fwcli.SetupField { - if existing == nil { - existing = map[string]string{} - } - - return []fwcli.SetupField{ -%s - } -} - -func replaceProfile(value, profile string) string { - return strings.ReplaceAll(value, "{profile}", strings.TrimSpace(profile)) -} -`, packageName, flagsBuilder.String(), specsBuilder.String(), setupBuilder.String()) - - return formatGenerated("config", source) -} - -func configFieldLabel(field manifest.ConfigField) string { - if label := strings.TrimSpace(field.Label); label != "" { - return label - } - return strings.TrimSpace(field.Name) -} - -func configSourceList(sources []string) string { - if len(sources) == 0 { - return "" - } - - parts := make([]string, 0, len(sources)) - for _, source := range sources { - switch strings.TrimSpace(source) { - case "flag": - parts = append(parts, "fwcli.SourceFlag") - case "env": - parts = append(parts, "fwcli.SourceEnv") - case "config": - parts = append(parts, "fwcli.SourceConfig") - case "secret": - parts = append(parts, "fwcli.SourceSecret") - } - } - - return strings.Join(parts, ", ") -} - -func configSetupFieldType(fieldType string) string { - switch strings.TrimSpace(fieldType) { - case "url": - return "fwcli.SetupFieldURL" - case "secret": - return "fwcli.SetupFieldSecret" - case "bool": - return "fwcli.SetupFieldBool" - case "list": - return "fwcli.SetupFieldList" - default: - return "fwcli.SetupFieldString" - } -} - -func formatGenerated(name, source string) (string, error) { - formatted, err := format.Source([]byte(source)) - if err != nil { - return "", fmt.Errorf("format generated %s: %w", name, err) - } - - return string(formatted), nil -} - -func writeGeneratedFile(path, content string, mode os.FileMode) error { - current, err := os.ReadFile(path) - if err == nil && bytes.Equal(current, []byte(content)) { - return nil - } - if err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("read generated file %q: %w", path, err) - } - - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("create generated directory %q: %w", dir, err) - } - - if mode == 0 { - mode = 0o644 - } - if err := os.WriteFile(path, []byte(content), mode); err != nil { - return fmt.Errorf("write generated file %q: %w", path, err) - } - - return nil -} diff --git a/generate/generate_test.go b/generate/generate_test.go deleted file mode 100644 index e60de93..0000000 --- a/generate/generate_test.go +++ /dev/null @@ -1,499 +0,0 @@ -package generate - -import ( - "errors" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" - "testing" -) - -func TestGenerateCreatesManifestLoader(t *testing.T) { - projectDir := newProject(t, ` -binary_name = "demo-mcp" -docs_url = "https://docs.example.com/demo" - -[bootstrap] -description = "Demo MCP" -`) - - result, err := Generate(Options{ProjectDir: projectDir}) - if err != nil { - t.Fatalf("Generate returned error: %v", err) - } - - if !slices.Equal(result.Files, defaultGeneratedFiles("mcpgen")) { - t.Fatalf("result files = %v", result.Files) - } - - generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go") - content, err := os.ReadFile(generatedPath) - if err != nil { - t.Fatalf("ReadFile generated manifest: %v", err) - } - - for _, snippet := range []string{ - "// Code generated by mcp-framework generate. DO NOT EDIT.", - "package mcpgen", - "import fwmanifest \"gitea.lclr.dev/AI/mcp-framework/manifest\"", - "const embeddedManifest = ", - "func LoadManifest(startDir string) (fwmanifest.File, string, error) {", - "return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)", - `binary_name = \"demo-mcp\"`, - } { - if !strings.Contains(string(content), snippet) { - t.Fatalf("generated manifest.go missing snippet %q:\n%s", snippet, content) - } - } -} - -func TestGenerateCreatesP1Helpers(t *testing.T) { - projectDir := newProject(t, ` -binary_name = "demo-mcp" -docs_url = "https://docs.example.com/demo" - -[update] -driver = "gitea" -repository = "org/demo-mcp" -base_url = "https://gitea.example.com" -asset_name_template = "{binary}-{os}-{arch}{ext}" - -[secret_store] -backend_policy = "env-only" - -[bootstrap] -description = "Demo MCP" -`) - - result, err := Generate(Options{ProjectDir: projectDir}) - if err != nil { - t.Fatalf("Generate returned error: %v", err) - } - - wantFiles := []string{ - filepath.Join("mcpgen", "manifest.go"), - filepath.Join("mcpgen", "metadata.go"), - filepath.Join("mcpgen", "secretstore.go"), - filepath.Join("mcpgen", "update.go"), - } - if !slices.Equal(result.Files, wantFiles) { - t.Fatalf("result files = %v, want %v", result.Files, wantFiles) - } - - metadata, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "metadata.go")) - if err != nil { - t.Fatalf("ReadFile metadata.go: %v", err) - } - for _, snippet := range []string{ - `const BinaryName = "demo-mcp"`, - `const DefaultDescription = "Demo MCP"`, - `const DocsURL = "https://docs.example.com/demo"`, - "func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) {", - "func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) {", - } { - if !strings.Contains(string(metadata), snippet) { - t.Fatalf("metadata.go missing snippet %q:\n%s", snippet, metadata) - } - } - - update, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "update.go")) - if err != nil { - t.Fatalf("ReadFile update.go: %v", err) - } - for _, snippet := range []string{ - "func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) {", - "func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) {", - "func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error {", - "ReleaseSource:", - } { - if !strings.Contains(string(update), snippet) { - t.Fatalf("update.go missing snippet %q:\n%s", snippet, update) - } - } - - secretstore, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go")) - if err != nil { - t.Fatalf("ReadFile secretstore.go: %v", err) - } - for _, snippet := range []string{ - "type SecretStoreOptions struct {", - "func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) {", - "func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) {", - "func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) {", - "ManifestLoader:", - } { - if !strings.Contains(string(secretstore), snippet) { - t.Fatalf("secretstore.go missing snippet %q:\n%s", snippet, secretstore) - } - } -} - -func TestGenerateCreatesConfigHelpersFromManifestFields(t *testing.T) { - projectDir := newProject(t, ` -binary_name = "demo-mcp" - -[[config.fields]] -name = "base_url" -flag = "base-url" -env = "BASE_URL" -config_key = "base_url" -type = "url" -label = "Graylog URL" -required = true -sources = ["flag", "env", "config"] - -[[config.fields]] -name = "api_token" -flag = "api-token" -env = "API_TOKEN" -secret_key_template = "profile/{profile}/api-token" -type = "secret" -label = "API token" -required = true -sources = ["flag", "env", "secret"] -`) - - result, err := Generate(Options{ProjectDir: projectDir}) - if err != nil { - t.Fatalf("Generate returned error: %v", err) - } - - wantFiles := generatedFilesWithConfig("mcpgen") - if !slices.Equal(result.Files, wantFiles) { - t.Fatalf("result files = %v, want %v", result.Files, wantFiles) - } - - config, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "config.go")) - if err != nil { - t.Fatalf("ReadFile config.go: %v", err) - } - for _, snippet := range []string{ - "type ConfigFlags struct {", - "func AddConfigFlags(fs *flag.FlagSet) ConfigFlags {", - "func ConfigFlagValues(flags ConfigFlags) map[string]string {", - "func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {", - "func SetupFields(existing map[string]string) []fwcli.SetupField {", - `fs.String("base-url", "", "Graylog URL")`, - `SecretKey: replaceProfile("profile/{profile}/api-token", profile)`, - "fwcli.SetupFieldURL", - "fwcli.SetupFieldSecret", - } { - if !strings.Contains(string(config), snippet) { - t.Fatalf("config.go missing snippet %q:\n%s", snippet, config) - } - } -} - -func TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) { - projectDir := newProject(t, `binary_name = "demo-mcp"`) - - if _, err := Generate(Options{ProjectDir: projectDir}); err != nil { - t.Fatalf("first Generate returned error: %v", err) - } - generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go") - first, err := os.ReadFile(generatedPath) - if err != nil { - t.Fatalf("ReadFile first generated file: %v", err) - } - - if _, err := Generate(Options{ProjectDir: projectDir}); err != nil { - t.Fatalf("second Generate returned error: %v", err) - } - second, err := os.ReadFile(generatedPath) - if err != nil { - t.Fatalf("ReadFile second generated file: %v", err) - } - if string(second) != string(first) { - t.Fatalf("second generation changed content") - } - - if _, err := Generate(Options{ProjectDir: projectDir, Check: true}); err != nil { - t.Fatalf("check after generation returned error: %v", err) - } - - if err := os.WriteFile(generatedPath, append(second, []byte("// drift\n")...), 0o600); err != nil { - t.Fatalf("WriteFile drift: %v", err) - } - - _, err = Generate(Options{ProjectDir: projectDir, Check: true}) - if !errors.Is(err, ErrGeneratedFilesOutdated) { - t.Fatalf("check error = %v, want ErrGeneratedFilesOutdated", err) - } -} - -func TestGenerateSupportsManifestAndPackageFlags(t *testing.T) { - projectDir := t.TempDir() - manifestPath := filepath.Join(projectDir, "config", "custom.toml") - if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { - t.Fatalf("MkdirAll manifest dir: %v", err) - } - if err := os.WriteFile(manifestPath, []byte(`binary_name = "demo-mcp"`), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - - result, err := Generate(Options{ - ProjectDir: projectDir, - ManifestPath: manifestPath, - PackageDir: "internal/generated", - PackageName: "generated", - }) - if err != nil { - t.Fatalf("Generate returned error: %v", err) - } - - if !slices.Equal(result.Files, defaultGeneratedFiles(filepath.Join("internal", "generated"))) { - t.Fatalf("result files = %v", result.Files) - } - - content, err := os.ReadFile(filepath.Join(projectDir, "internal", "generated", "manifest.go")) - if err != nil { - t.Fatalf("ReadFile generated manifest: %v", err) - } - if !strings.Contains(string(content), "package generated") { - t.Fatalf("generated file should use package name: %s", content) - } -} - -func TestGenerateRejectsInvalidManifest(t *testing.T) { - projectDir := newProject(t, "[bootstrap\n") - - _, err := Generate(Options{ProjectDir: projectDir}) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "parse manifest") { - t.Fatalf("error = %v", err) - } -} - -func TestGeneratedLoaderFallsBackToEmbeddedManifest(t *testing.T) { - projectDir := newProject(t, ` -binary_name = "embedded-demo" -docs_url = "https://docs.example.com/embedded" - -[update] -driver = "gitea" -repository = "org/embedded-demo" -base_url = "https://gitea.example.com" - -[secret_store] -backend_policy = "env-only" - -[bootstrap] -description = "Embedded Demo" - -[[config.fields]] -name = "base_url" -flag = "base-url" -env = "BASE_URL" -config_key = "base_url" -type = "url" -label = "Base URL" -required = true -sources = ["flag", "env", "config"] - -[[config.fields]] -name = "api_token" -flag = "api-token" -env = "API_TOKEN" -secret_key_template = "profile/{profile}/api-token" -type = "secret" -label = "API token" -required = true -sources = ["flag", "env", "secret"] -`) - writeModule(t, projectDir) - - if _, err := Generate(Options{ProjectDir: projectDir}); err != nil { - t.Fatalf("Generate returned error: %v", err) - } - - if err := os.Remove(filepath.Join(projectDir, "mcp.toml")); err != nil { - t.Fatalf("Remove runtime manifest: %v", err) - } - - cmd := exec.Command("go", "test", "-mod=mod", "./...") - cmd.Dir = projectDir - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("go test generated project: %v\n%s", err, output) - } -} - -func TestGenerateSecretStoreIncludesBitwardenCacheOption(t *testing.T) { - projectDir := newProject(t, ` -binary_name = "demo-mcp" - -[secret_store] -backend_policy = "bitwarden-cli" -`) - - if _, err := Generate(Options{ProjectDir: projectDir}); err != nil { - t.Fatalf("Generate returned error: %v", err) - } - - content, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go")) - if err != nil { - t.Fatalf("ReadFile generated secretstore: %v", err) - } - text := string(content) - for _, snippet := range []string{ - "DisableBitwardenCache bool", - "DisableBitwardenCache: options.DisableBitwardenCache,", - } { - if !strings.Contains(text, snippet) { - t.Fatalf("generated secretstore.go missing %q:\n%s", snippet, text) - } - } -} - -func newProject(t *testing.T, manifest string) string { - t.Helper() - - projectDir := t.TempDir() - if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(manifest), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - return projectDir -} - -func defaultGeneratedFiles(packageDir string) []string { - return []string{ - filepath.Join(packageDir, "manifest.go"), - filepath.Join(packageDir, "metadata.go"), - filepath.Join(packageDir, "secretstore.go"), - filepath.Join(packageDir, "update.go"), - } -} - -func generatedFilesWithConfig(packageDir string) []string { - return []string{ - filepath.Join(packageDir, "config.go"), - filepath.Join(packageDir, "manifest.go"), - filepath.Join(packageDir, "metadata.go"), - filepath.Join(packageDir, "secretstore.go"), - filepath.Join(packageDir, "update.go"), - } -} - -func writeModule(t *testing.T, projectDir string) { - t.Helper() - - repoRoot, err := filepath.Abs("..") - if err != nil { - t.Fatalf("Abs repo root: %v", err) - } - - goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n" - if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil { - t.Fatalf("WriteFile go.mod: %v", err) - } - - goSum, err := os.ReadFile(filepath.Join(repoRoot, "go.sum")) - if err != nil { - t.Fatalf("ReadFile go.sum: %v", err) - } - if err := os.WriteFile(filepath.Join(projectDir, "go.sum"), goSum, 0o600); err != nil { - t.Fatalf("WriteFile go.sum: %v", err) - } - - testFile := `package main - -import ( - "flag" - "io" - "testing" - - fwcli "gitea.lclr.dev/AI/mcp-framework/cli" - fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" - "example.com/generated-demo/mcpgen" - fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" -) - -func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) { - file, source, err := mcpgen.LoadManifest(".") - if err != nil { - t.Fatalf("LoadManifest returned error: %v", err) - } - if source != fwmanifest.EmbeddedSource { - t.Fatalf("source = %q, want %q", source, fwmanifest.EmbeddedSource) - } - if file.BinaryName != "embedded-demo" { - t.Fatalf("binary name = %q", file.BinaryName) - } - - info, source, err := mcpgen.BootstrapInfo(".") - if err != nil { - t.Fatalf("BootstrapInfo returned error: %v", err) - } - if source != fwmanifest.EmbeddedSource { - t.Fatalf("bootstrap source = %q, want %q", source, fwmanifest.EmbeddedSource) - } - if info.Description != "Embedded Demo" { - t.Fatalf("description = %q", info.Description) - } - - updateOptions, err := mcpgen.UpdateOptions("1.2.3", io.Discard) - if err != nil { - t.Fatalf("UpdateOptions returned error: %v", err) - } - if updateOptions.CurrentVersion != "1.2.3" { - t.Fatalf("current version = %q", updateOptions.CurrentVersion) - } - if updateOptions.BinaryName != "embedded-demo" { - t.Fatalf("update binary name = %q", updateOptions.BinaryName) - } - if updateOptions.ReleaseSource.Repository != "org/embedded-demo" { - t.Fatalf("release repository = %q", updateOptions.ReleaseSource.Repository) - } - - store, err := mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{ - LookupEnv: func(name string) (string, bool) { - return "secret-from-env", true - }, - }) - if err != nil { - t.Fatalf("OpenSecretStore returned error: %v", err) - } - value, err := store.GetSecret("profile/default/api-token") - if err != nil { - t.Fatalf("GetSecret returned error: %v", err) - } - if value != "secret-from-env" { - t.Fatalf("secret value = %q", value) - } - if fwsecretstore.EffectiveBackendPolicy(store) != fwsecretstore.BackendEnvOnly { - t.Fatalf("effective backend = %q", fwsecretstore.EffectiveBackendPolicy(store)) - } - - flags := mcpgen.AddConfigFlags(flag.NewFlagSet("test", flag.ContinueOnError)) - if len(mcpgen.ConfigFlagValues(flags)) != 0 { - t.Fatalf("empty flags should not return values") - } - - specs := mcpgen.ResolveFieldSpecs("default") - if len(specs) != 2 { - t.Fatalf("field specs = %d, want 2", len(specs)) - } - if specs[1].SecretKey != "profile/default/api-token" { - t.Fatalf("secret key = %q", specs[1].SecretKey) - } - - setupFields := mcpgen.SetupFields(map[string]string{"api_token": "stored"}) - if len(setupFields) != 2 { - t.Fatalf("setup fields = %d, want 2", len(setupFields)) - } - if setupFields[0].Type != fwcli.SetupFieldURL { - t.Fatalf("first setup field type = %q", setupFields[0].Type) - } - if setupFields[1].ExistingSecret != "stored" { - t.Fatalf("existing secret = %q", setupFields[1].ExistingSecret) - } -} -` - if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil { - t.Fatalf("WriteFile main_test.go: %v", err) - } -} diff --git a/manifest/manifest.go b/manifest/manifest.go index d82acb3..2bb8ca1 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -13,87 +13,17 @@ import ( ) const DefaultFile = "mcp.toml" -const EmbeddedSource = "embedded:mcp.toml" type File struct { - BinaryName string `toml:"binary_name"` - DocsURL string `toml:"docs_url"` - Update Update `toml:"update"` - Environment Environment `toml:"environment"` - SecretStore SecretStore `toml:"secret_store"` - Profiles Profiles `toml:"profiles"` - Bootstrap Bootstrap `toml:"bootstrap"` - Config Config `toml:"config"` + Update Update `toml:"update"` } type Update struct { - SourceName string `toml:"source_name"` - Driver string `toml:"driver"` - Repository string `toml:"repository"` - BaseURL string `toml:"base_url"` - LatestReleaseURL string `toml:"latest_release_url"` - AssetNameTemplate string `toml:"asset_name_template"` - ChecksumAssetName string `toml:"checksum_asset_name"` - ChecksumRequired bool `toml:"checksum_required"` - SignatureAssetName string `toml:"signature_asset_name"` - SignatureRequired bool `toml:"signature_required"` - SignaturePublicKey string `toml:"signature_public_key"` - SignaturePublicKeyEnvNames []string `toml:"signature_public_key_env_names"` - TokenHeader string `toml:"token_header"` - TokenPrefix string `toml:"token_prefix"` - TokenEnvNames []string `toml:"token_env_names"` -} - -type Environment struct { - Known []string `toml:"known"` -} - -type SecretStore struct { - BackendPolicy string `toml:"backend_policy"` - BitwardenCache *bool `toml:"bitwarden_cache"` -} - -type Profiles struct { - Default string `toml:"default"` - Known []string `toml:"known"` -} - -type Bootstrap struct { - Description string `toml:"description"` -} - -type Config struct { - Fields []ConfigField `toml:"fields"` -} - -type ConfigField struct { - Name string `toml:"name"` - Flag string `toml:"flag"` - Env string `toml:"env"` - ConfigKey string `toml:"config_key"` - SecretKeyTemplate string `toml:"secret_key_template"` - Type string `toml:"type"` - Label string `toml:"label"` - Default string `toml:"default"` - Required bool `toml:"required"` - Sources []string `toml:"sources"` -} - -type BootstrapMetadata struct { - BinaryName string - Description string - DocsURL string - DefaultProfile string - Profiles []string -} - -type ScaffoldMetadata struct { - BinaryName string - DocsURL string - KnownEnvironmentVariables []string - SecretStorePolicy string - DefaultProfile string - Profiles []string + SourceName string `toml:"source_name"` + BaseURL string `toml:"base_url"` + LatestReleaseURL string `toml:"latest_release_url"` + TokenHeader string `toml:"token_header"` + TokenEnvNames []string `toml:"token_env_names"` } func Find(startDir string) (string, error) { @@ -138,39 +68,9 @@ func Load(path string) (File, error) { return File{}, fmt.Errorf("read manifest %s: %w", path, err) } - return parse(data, path) -} - -func LoadEmbedded(content string) (File, string, error) { - trimmed := strings.TrimSpace(content) - if trimmed == "" { - return File{}, "", os.ErrNotExist - } - - file, err := parse([]byte(trimmed), EmbeddedSource) - if err != nil { - return File{}, "", err - } - - return file, EmbeddedSource, nil -} - -func LoadDefaultOrEmbedded(startDir, embeddedContent string) (File, string, error) { - file, path, err := LoadDefault(startDir) - if err == nil { - return file, path, nil - } - if !errors.Is(err, os.ErrNotExist) { - return File{}, "", err - } - - return LoadEmbedded(embeddedContent) -} - -func parse(data []byte, source string) (File, error) { var file File if err := toml.Unmarshal(data, &file); err != nil { - return File{}, fmt.Errorf("parse manifest %s: %w", source, err) + return File{}, fmt.Errorf("parse manifest %s: %w", path, err) } file.normalize() @@ -192,120 +92,32 @@ func LoadDefault(startDir string) (File, string, error) { } func (f *File) normalize() { - f.BinaryName = strings.TrimSpace(f.BinaryName) - f.DocsURL = strings.TrimSpace(f.DocsURL) f.Update.normalize() - f.Environment.normalize() - f.SecretStore.normalize() - f.Profiles.normalize() - f.Bootstrap.normalize() - f.Config.normalize() } func (u *Update) normalize() { u.SourceName = strings.TrimSpace(u.SourceName) - u.Driver = strings.ToLower(strings.TrimSpace(u.Driver)) - u.Repository = strings.Trim(strings.TrimSpace(u.Repository), "/") u.BaseURL = strings.TrimRight(strings.TrimSpace(u.BaseURL), "/") u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL) - u.AssetNameTemplate = strings.TrimSpace(u.AssetNameTemplate) - u.ChecksumAssetName = strings.TrimSpace(u.ChecksumAssetName) - u.SignatureAssetName = strings.TrimSpace(u.SignatureAssetName) - u.SignaturePublicKey = strings.TrimSpace(u.SignaturePublicKey) - u.SignaturePublicKeyEnvNames = normalizeStringList(u.SignaturePublicKeyEnvNames) u.TokenHeader = strings.TrimSpace(u.TokenHeader) - u.TokenPrefix = strings.TrimSpace(u.TokenPrefix) - u.TokenEnvNames = normalizeStringList(u.TokenEnvNames) -} -func (e *Environment) normalize() { - e.Known = normalizeStringList(e.Known) -} - -func (s *SecretStore) normalize() { - s.BackendPolicy = strings.TrimSpace(s.BackendPolicy) -} - -func (p *Profiles) normalize() { - p.Default = strings.TrimSpace(p.Default) - p.Known = normalizeStringList(p.Known) -} - -func (b *Bootstrap) normalize() { - b.Description = strings.TrimSpace(b.Description) -} - -func (c *Config) normalize() { - for i := range c.Fields { - c.Fields[i].normalize() + envNames := u.TokenEnvNames[:0] + for _, envName := range u.TokenEnvNames { + if trimmed := strings.TrimSpace(envName); trimmed != "" { + envNames = append(envNames, trimmed) + } } -} - -func (f *ConfigField) normalize() { - f.Name = strings.TrimSpace(f.Name) - f.Flag = strings.TrimSpace(f.Flag) - f.Env = strings.TrimSpace(f.Env) - f.ConfigKey = strings.TrimSpace(f.ConfigKey) - f.SecretKeyTemplate = strings.TrimSpace(f.SecretKeyTemplate) - f.Type = strings.ToLower(strings.TrimSpace(f.Type)) - f.Label = strings.TrimSpace(f.Label) - f.Default = strings.TrimSpace(f.Default) - f.Sources = normalizeStringList(f.Sources) + u.TokenEnvNames = envNames } func (u Update) ReleaseSource() update.ReleaseSource { u.normalize() return update.ReleaseSource{ - Name: u.SourceName, - Driver: u.Driver, - Repository: u.Repository, - BaseURL: u.BaseURL, - LatestReleaseURL: u.LatestReleaseURL, - AssetNameTemplate: u.AssetNameTemplate, - ChecksumAssetName: u.ChecksumAssetName, - ChecksumRequired: u.ChecksumRequired, - SignatureAssetName: u.SignatureAssetName, - SignatureRequired: u.SignatureRequired, - SignaturePublicKey: u.SignaturePublicKey, - SignaturePublicKeyEnvNames: append([]string(nil), u.SignaturePublicKeyEnvNames...), - TokenHeader: u.TokenHeader, - TokenPrefix: u.TokenPrefix, - TokenEnvNames: append([]string(nil), u.TokenEnvNames...), + Name: u.SourceName, + BaseURL: u.BaseURL, + LatestReleaseURL: u.LatestReleaseURL, + TokenHeader: u.TokenHeader, + TokenEnvNames: append([]string(nil), u.TokenEnvNames...), } } - -func (f File) BootstrapInfo() BootstrapMetadata { - f.normalize() - - return BootstrapMetadata{ - BinaryName: f.BinaryName, - Description: f.Bootstrap.Description, - DocsURL: f.DocsURL, - DefaultProfile: f.Profiles.Default, - Profiles: append([]string(nil), f.Profiles.Known...), - } -} - -func (f File) ScaffoldInfo() ScaffoldMetadata { - f.normalize() - - return ScaffoldMetadata{ - BinaryName: f.BinaryName, - DocsURL: f.DocsURL, - KnownEnvironmentVariables: append([]string(nil), f.Environment.Known...), - SecretStorePolicy: f.SecretStore.BackendPolicy, - DefaultProfile: f.Profiles.Default, - Profiles: append([]string(nil), f.Profiles.Known...), - } -} - -func normalizeStringList(values []string) []string { - normalized := values[:0] - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - normalized = append(normalized, trimmed) - } - } - return normalized -} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 6b18139..cfd35ec 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -4,7 +4,6 @@ import ( "errors" "os" "path/filepath" - "slices" "testing" ) @@ -46,19 +45,9 @@ func TestLoadParsesUpdateConfig(t *testing.T) { const content = ` [update] source_name = " Gitea releases " -driver = " Gitea " -repository = " org/repo " base_url = "https://gitea.example.com/" latest_release_url = "https://gitea.example.com/api/releases/latest" -asset_name_template = "{binary}_{os}_{arch}{ext}" -checksum_asset_name = "{asset}.sha256" -checksum_required = true -signature_asset_name = "{asset}.sig" -signature_required = true -signature_public_key = " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " -signature_public_key_env_names = [" MCP_PUBKEY ", "", "MCP_RELEASE_PUBKEY"] token_header = " Authorization " -token_prefix = " token " token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"] ` @@ -75,45 +64,15 @@ token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"] if source.Name != "Gitea releases" { t.Fatalf("source name = %q", source.Name) } - if source.Driver != "gitea" { - t.Fatalf("driver = %q", source.Driver) - } - if source.Repository != "org/repo" { - t.Fatalf("repository = %q", source.Repository) - } if source.BaseURL != "https://gitea.example.com" { t.Fatalf("base URL = %q", source.BaseURL) } if source.LatestReleaseURL != "https://gitea.example.com/api/releases/latest" { t.Fatalf("latest release URL = %q", source.LatestReleaseURL) } - if source.AssetNameTemplate != "{binary}_{os}_{arch}{ext}" { - t.Fatalf("asset name template = %q", source.AssetNameTemplate) - } - if source.ChecksumAssetName != "{asset}.sha256" { - t.Fatalf("checksum asset name = %q", source.ChecksumAssetName) - } - if !source.ChecksumRequired { - t.Fatal("checksum required should be true") - } - if source.SignatureAssetName != "{asset}.sig" { - t.Fatalf("signature asset name = %q", source.SignatureAssetName) - } - if !source.SignatureRequired { - t.Fatal("signature required should be true") - } - if source.SignaturePublicKey == "" { - t.Fatal("signature public key should be set") - } - if len(source.SignaturePublicKeyEnvNames) != 2 { - t.Fatalf("signature env names = %v", source.SignaturePublicKeyEnvNames) - } if source.TokenHeader != "Authorization" { t.Fatalf("token header = %q", source.TokenHeader) } - if source.TokenPrefix != "token" { - t.Fatalf("token prefix = %q", source.TokenPrefix) - } if len(source.TokenEnvNames) != 2 { t.Fatalf("token env names = %v", source.TokenEnvNames) } @@ -155,264 +114,3 @@ func TestLoadReturnsParseError(t *testing.T) { t.Fatal("expected error") } } - -func TestLoadParsesExtendedManifestMetadata(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, DefaultFile) - - const content = ` -binary_name = " my-mcp " -docs_url = " https://docs.example.com/mcp " - -[update] -latest_release_url = "https://example.com/latest" - -[environment] -known = [" MCP_PROFILE ", "", "MCP_TOKEN"] - -[secret_store] -backend_policy = " auto " - -[profiles] -default = " prod " -known = [" default ", "", "prod"] - -[bootstrap] -description = " Client MCP interne " -` - - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - - file, err := Load(path) - if err != nil { - t.Fatalf("Load returned error: %v", err) - } - - if file.BinaryName != "my-mcp" { - t.Fatalf("binary name = %q", file.BinaryName) - } - if file.DocsURL != "https://docs.example.com/mcp" { - t.Fatalf("docs URL = %q", file.DocsURL) - } - if !slices.Equal(file.Environment.Known, []string{"MCP_PROFILE", "MCP_TOKEN"}) { - t.Fatalf("environment known = %v", file.Environment.Known) - } - if file.SecretStore.BackendPolicy != "auto" { - t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy) - } - if file.Profiles.Default != "prod" { - t.Fatalf("default profile = %q", file.Profiles.Default) - } - if !slices.Equal(file.Profiles.Known, []string{"default", "prod"}) { - t.Fatalf("profiles known = %v", file.Profiles.Known) - } - if file.Bootstrap.Description != "Client MCP interne" { - t.Fatalf("bootstrap description = %q", file.Bootstrap.Description) - } - - bootstrap := file.BootstrapInfo() - if bootstrap.BinaryName != "my-mcp" { - t.Fatalf("bootstrap binary name = %q", bootstrap.BinaryName) - } - if bootstrap.DocsURL != "https://docs.example.com/mcp" { - t.Fatalf("bootstrap docs URL = %q", bootstrap.DocsURL) - } - if bootstrap.DefaultProfile != "prod" { - t.Fatalf("bootstrap default profile = %q", bootstrap.DefaultProfile) - } - if !slices.Equal(bootstrap.Profiles, []string{"default", "prod"}) { - t.Fatalf("bootstrap profiles = %v", bootstrap.Profiles) - } - - scaffold := file.ScaffoldInfo() - if scaffold.SecretStorePolicy != "auto" { - t.Fatalf("scaffold secret store policy = %q", scaffold.SecretStorePolicy) - } - if !slices.Equal(scaffold.KnownEnvironmentVariables, []string{"MCP_PROFILE", "MCP_TOKEN"}) { - t.Fatalf("scaffold known environment variables = %v", scaffold.KnownEnvironmentVariables) - } -} - -func TestLoadParsesSecretStoreBitwardenCache(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, DefaultFile) - - const content = ` -[secret_store] -backend_policy = "bitwarden-cli" -bitwarden_cache = false -` - - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - - file, err := Load(path) - if err != nil { - t.Fatalf("Load returned error: %v", err) - } - if file.SecretStore.BitwardenCache == nil { - t.Fatal("bitwarden cache option is nil, want explicit false pointer") - } - if *file.SecretStore.BitwardenCache { - t.Fatal("bitwarden cache option = true, want false") - } -} - -func TestLoadLeavesOmittedBitwardenCacheUnset(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, DefaultFile) - - const content = ` -[secret_store] -backend_policy = "bitwarden-cli" -` - - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - - file, err := Load(path) - if err != nil { - t.Fatalf("Load returned error: %v", err) - } - if file.SecretStore.BitwardenCache != nil { - t.Fatalf("bitwarden cache option = %v, want nil when omitted", *file.SecretStore.BitwardenCache) - } -} - -func TestLoadParsesConfigFields(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, DefaultFile) - - const content = ` -[[config.fields]] -name = " base_url " -flag = "base-url" -env = "BASE_URL" -config_key = "base_url" -type = " url " -label = " Graylog URL " -required = true -sources = [" flag ", "env", "config"] - -[[config.fields]] -name = "api_token" -flag = "api-token" -env = "API_TOKEN" -secret_key_template = "profile/{profile}/api-token" -type = "secret" -required = true -sources = ["flag", "env", "secret"] -` - - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - - file, err := Load(path) - if err != nil { - t.Fatalf("Load returned error: %v", err) - } - - if len(file.Config.Fields) != 2 { - t.Fatalf("config fields = %d, want 2", len(file.Config.Fields)) - } - - baseURL := file.Config.Fields[0] - if baseURL.Name != "base_url" { - t.Fatalf("base URL name = %q", baseURL.Name) - } - if baseURL.Flag != "base-url" { - t.Fatalf("base URL flag = %q", baseURL.Flag) - } - if baseURL.Env != "BASE_URL" { - t.Fatalf("base URL env = %q", baseURL.Env) - } - if baseURL.ConfigKey != "base_url" { - t.Fatalf("base URL config key = %q", baseURL.ConfigKey) - } - if baseURL.Type != "url" { - t.Fatalf("base URL type = %q", baseURL.Type) - } - if baseURL.Label != "Graylog URL" { - t.Fatalf("base URL label = %q", baseURL.Label) - } - if !baseURL.Required { - t.Fatal("base URL should be required") - } - if !slices.Equal(baseURL.Sources, []string{"flag", "env", "config"}) { - t.Fatalf("base URL sources = %v", baseURL.Sources) - } - - token := file.Config.Fields[1] - if token.SecretKeyTemplate != "profile/{profile}/api-token" { - t.Fatalf("token secret key template = %q", token.SecretKeyTemplate) - } - if !slices.Equal(token.Sources, []string{"flag", "env", "secret"}) { - t.Fatalf("token sources = %v", token.Sources) - } -} - -func TestLoadEmbeddedParsesContent(t *testing.T) { - file, source, err := LoadEmbedded(` -[update] -latest_release_url = "https://example.com/latest" -`) - if err != nil { - t.Fatalf("LoadEmbedded returned error: %v", err) - } - if source != EmbeddedSource { - t.Fatalf("source = %q, want %q", source, EmbeddedSource) - } - if file.Update.LatestReleaseURL != "https://example.com/latest" { - t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL) - } -} - -func TestLoadEmbeddedReturnsNotExistWhenEmpty(t *testing.T) { - _, _, err := LoadEmbedded(" ") - if !errors.Is(err, os.ErrNotExist) { - t.Fatalf("error = %v, want os.ErrNotExist", err) - } -} - -func TestLoadDefaultOrEmbeddedPrefersManifestFile(t *testing.T) { - root := t.TempDir() - path := filepath.Join(root, DefaultFile) - if err := os.WriteFile(path, []byte("[update]\nlatest_release_url = \"https://example.com/from-file\"\n"), 0o600); err != nil { - t.Fatalf("WriteFile manifest: %v", err) - } - - file, source, err := LoadDefaultOrEmbedded(root, ` -[update] -latest_release_url = "https://example.com/from-embedded" -`) - if err != nil { - t.Fatalf("LoadDefaultOrEmbedded returned error: %v", err) - } - if source != path { - t.Fatalf("source = %q, want %q", source, path) - } - if file.Update.LatestReleaseURL != "https://example.com/from-file" { - t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL) - } -} - -func TestLoadDefaultOrEmbeddedUsesEmbeddedWhenFileMissing(t *testing.T) { - file, source, err := LoadDefaultOrEmbedded(t.TempDir(), ` -[update] -latest_release_url = "https://example.com/from-embedded" -`) - if err != nil { - t.Fatalf("LoadDefaultOrEmbedded returned error: %v", err) - } - if source != EmbeddedSource { - t.Fatalf("source = %q, want %q", source, EmbeddedSource) - } - if file.Update.LatestReleaseURL != "https://example.com/from-embedded" { - t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL) - } -} diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go deleted file mode 100644 index ca72024..0000000 --- a/scaffold/scaffold.go +++ /dev/null @@ -1,2136 +0,0 @@ -package scaffold - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "slices" - "sort" - "strings" - "text/template" - "unicode" -) - -var ( - ErrTargetDirRequired = errors.New("target directory is required") - ErrFileExists = errors.New("target file already exists") -) - -type Options struct { - TargetDir string - ModulePath string - BinaryName string - Description string - DocsURL string - DefaultProfile string - Profiles []string - KnownEnvironmentVariables []string - SecretStorePolicy string - ReleaseDriver string - ReleaseBaseURL string - ReleaseRepository string - ReleaseTokenEnv string - ReleasePublicKeyEnv string - Overwrite bool -} - -type Result struct { - Root string - Files []string -} - -type normalizedOptions struct { - TargetDir string - ModulePath string - BinaryName string - Description string - DocsURL string - DefaultProfile string - Profiles []string - KnownEnvironmentVariables []string - ProfileEnv string - BaseURLEnv string - TokenEnv string - SecretStorePolicy string - ReleaseDriver string - ReleaseBaseURL string - ReleaseRepository string - ReleaseTokenEnv string - ReleasePublicKeyEnv string - Overwrite bool -} - -func Generate(options Options) (Result, error) { - normalized, err := normalizeOptions(options) - if err != nil { - return Result{}, err - } - - if err := os.MkdirAll(normalized.TargetDir, 0o755); err != nil { - return Result{}, fmt.Errorf("create scaffold target %q: %w", normalized.TargetDir, err) - } - - files := []generatedFile{ - {Path: ".gitignore", Template: gitignoreTemplate, Mode: 0o644}, - {Path: "go.mod", Template: goModTemplate, Mode: 0o644}, - {Path: "README.md", Template: readmeTemplate, Mode: 0o644}, - {Path: "install.sh", Template: installTemplate, Mode: 0o755}, - {Path: "mcp.toml", Template: manifestTemplate, Mode: 0o644}, - {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Template: mainTemplate, Mode: 0o644}, - {Path: filepath.Join("internal", "app", "app.go"), Template: appTemplate, Mode: 0o644}, - } - - written := make([]string, 0, len(files)) - for _, file := range files { - content, err := renderTemplate(file.Template, normalized) - if err != nil { - return Result{}, fmt.Errorf("render scaffold file %q: %w", file.Path, err) - } - - fullPath := filepath.Join(normalized.TargetDir, file.Path) - if err := writeFile(fullPath, content, file.Mode, normalized.Overwrite); err != nil { - return Result{}, err - } - written = append(written, file.Path) - } - - sort.Strings(written) - return Result{ - Root: normalized.TargetDir, - Files: written, - }, nil -} - -type generatedFile struct { - Path string - Template string - Mode os.FileMode -} - -func writeFile(path, content string, mode os.FileMode, overwrite bool) error { - if !overwrite { - if _, err := os.Stat(path); err == nil { - return fmt.Errorf("%w: %s", ErrFileExists, path) - } else if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("stat scaffold file %q: %w", path, err) - } - } - - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("create scaffold directory %q: %w", dir, err) - } - - if mode == 0 { - mode = 0o644 - } - - if err := os.WriteFile(path, []byte(content), mode); err != nil { - return fmt.Errorf("write scaffold file %q: %w", path, err) - } - - return nil -} - -func renderTemplate(src string, data normalizedOptions) (string, error) { - tpl, err := template.New("scaffold").Parse(src) - if err != nil { - return "", fmt.Errorf("parse template: %w", err) - } - - var builder strings.Builder - if err := tpl.Execute(&builder, data); err != nil { - return "", fmt.Errorf("execute template: %w", err) - } - - return builder.String(), nil -} - -func normalizeOptions(options Options) (normalizedOptions, error) { - targetDir := strings.TrimSpace(options.TargetDir) - if targetDir == "" { - return normalizedOptions{}, ErrTargetDirRequired - } - - resolvedTarget, err := filepath.Abs(targetDir) - if err != nil { - return normalizedOptions{}, fmt.Errorf("resolve scaffold target %q: %w", targetDir, err) - } - - binaryName := strings.TrimSpace(options.BinaryName) - if binaryName == "" { - binaryName = sanitizeSlug(filepath.Base(resolvedTarget)) - } - if binaryName == "" { - binaryName = "my-mcp" - } - if strings.ContainsRune(binaryName, os.PathSeparator) { - return normalizedOptions{}, fmt.Errorf("binary name %q must not contain path separators", binaryName) - } - - modulePath := strings.TrimSpace(options.ModulePath) - if modulePath == "" { - modulePath = fmt.Sprintf("example.com/%s", sanitizeModuleSegment(binaryName)) - } - - description := strings.TrimSpace(options.Description) - if description == "" { - description = fmt.Sprintf("Binaire MCP %s.", binaryName) - } - - docsURL := strings.TrimSpace(options.DocsURL) - if docsURL == "" { - docsURL = fmt.Sprintf("https://docs.example.com/%s", binaryName) - } - - defaultProfile := strings.TrimSpace(options.DefaultProfile) - if defaultProfile == "" { - defaultProfile = "default" - } - - profiles := normalizeValues(options.Profiles) - if !slices.Contains(profiles, defaultProfile) { - profiles = append([]string{defaultProfile}, profiles...) - } - if len(profiles) == 0 { - profiles = []string{defaultProfile} - } - - envPrefix := environmentPrefix(binaryName) - profileEnv := envPrefix + "_PROFILE" - baseURLEnv := envPrefix + "_BASE_URL" - tokenEnv := envPrefix + "_API_TOKEN" - - knownEnvironmentVariables := []string{profileEnv, baseURLEnv, tokenEnv} - for _, name := range normalizeValues(options.KnownEnvironmentVariables) { - if !slices.Contains(knownEnvironmentVariables, name) { - knownEnvironmentVariables = append(knownEnvironmentVariables, name) - } - } - - secretStorePolicy := strings.TrimSpace(options.SecretStorePolicy) - if secretStorePolicy == "" { - secretStorePolicy = "auto" - } - - releaseDriver := strings.TrimSpace(options.ReleaseDriver) - if releaseDriver == "" { - releaseDriver = "gitea" - } - - releaseBaseURL := strings.TrimSpace(options.ReleaseBaseURL) - if releaseBaseURL == "" { - releaseBaseURL = "https://gitea.example.com" - } - - releaseRepository := strings.Trim(strings.TrimSpace(options.ReleaseRepository), "/") - if releaseRepository == "" { - releaseRepository = fmt.Sprintf("org/%s", binaryName) - } - - releaseTokenEnv := strings.TrimSpace(options.ReleaseTokenEnv) - if releaseTokenEnv == "" { - releaseTokenEnv = envPrefix + "_RELEASE_TOKEN" - } - releasePublicKeyEnv := strings.TrimSpace(options.ReleasePublicKeyEnv) - if releasePublicKeyEnv == "" { - releasePublicKeyEnv = envPrefix + "_RELEASE_ED25519_PUBLIC_KEY" - } - if !slices.Contains(knownEnvironmentVariables, releasePublicKeyEnv) { - knownEnvironmentVariables = append(knownEnvironmentVariables, releasePublicKeyEnv) - } - - return normalizedOptions{ - TargetDir: resolvedTarget, - ModulePath: modulePath, - BinaryName: binaryName, - Description: description, - DocsURL: docsURL, - DefaultProfile: defaultProfile, - Profiles: profiles, - KnownEnvironmentVariables: knownEnvironmentVariables, - ProfileEnv: profileEnv, - BaseURLEnv: baseURLEnv, - TokenEnv: tokenEnv, - SecretStorePolicy: secretStorePolicy, - ReleaseDriver: releaseDriver, - ReleaseBaseURL: releaseBaseURL, - ReleaseRepository: releaseRepository, - ReleaseTokenEnv: releaseTokenEnv, - ReleasePublicKeyEnv: releasePublicKeyEnv, - Overwrite: options.Overwrite, - }, nil -} - -func normalizeValues(values []string) []string { - normalized := make([]string, 0, len(values)) - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - continue - } - normalized = append(normalized, trimmed) - } - return normalized -} - -func sanitizeSlug(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" { - return "" - } - - var builder strings.Builder - lastDash := false - for _, r := range value { - switch { - case unicode.IsLetter(r) || unicode.IsDigit(r): - builder.WriteRune(r) - lastDash = false - case r == '-' || r == '_' || r == ' ' || r == '.': - if !lastDash && builder.Len() > 0 { - builder.WriteRune('-') - lastDash = true - } - } - } - - result := strings.Trim(builder.String(), "-") - if result == "" { - return "my-mcp" - } - - return result -} - -func sanitizeModuleSegment(binaryName string) string { - segment := sanitizeSlug(binaryName) - if segment == "" { - return "my-mcp" - } - return segment -} - -func environmentPrefix(binaryName string) string { - name := strings.ToUpper(strings.TrimSpace(binaryName)) - if name == "" { - return "MCP" - } - - var builder strings.Builder - lastUnderscore := false - for _, r := range name { - switch { - case unicode.IsLetter(r) || unicode.IsDigit(r): - builder.WriteRune(r) - lastUnderscore = false - default: - if !lastUnderscore { - builder.WriteRune('_') - lastUnderscore = true - } - } - } - - result := strings.Trim(builder.String(), "_") - if result == "" { - return "MCP" - } - return result -} - -const gitignoreTemplate = `bin/ -dist/ -*.log -` - -const goModTemplate = `module {{.ModulePath}} - -go 1.25.0 -` - -const installTemplate = `#!/usr/bin/env bash -set -euo pipefail - -BINARY_NAME="{{.BinaryName}}" -MODULE_PATH="{{.ModulePath}}" -DEFAULT_PROFILE="{{.DefaultProfile}}" -PROFILE_ENV="{{.ProfileEnv}}" -DEFAULT_RELEASE_DRIVER="{{.ReleaseDriver}}" -DEFAULT_RELEASE_BASE_URL="{{.ReleaseBaseURL}}" -DEFAULT_RELEASE_REPOSITORY="{{.ReleaseRepository}}" -DEFAULT_ASSET_NAME_TEMPLATE="{binary}-{os}-{arch}{ext}" -DEFAULT_CHECKSUM_ASSET_NAME="{asset}.sha256" -DEFAULT_CHECKSUM_REQUIRED="true" -DEFAULT_TOKEN_HEADER="Authorization" -DEFAULT_TOKEN_PREFIX="token" -DEFAULT_TOKEN_ENV_NAME="{{.ReleaseTokenEnv}}" -RELEASE_DRIVER="$DEFAULT_RELEASE_DRIVER" -RELEASE_BASE_URL="$DEFAULT_RELEASE_BASE_URL" -RELEASE_REPOSITORY="$DEFAULT_RELEASE_REPOSITORY" -LATEST_RELEASE_URL="" -ASSET_NAME_TEMPLATE="$DEFAULT_ASSET_NAME_TEMPLATE" -CHECKSUM_ASSET_NAME="$DEFAULT_CHECKSUM_ASSET_NAME" -CHECKSUM_REQUIRED="$DEFAULT_CHECKSUM_REQUIRED" -TOKEN_HEADER="$DEFAULT_TOKEN_HEADER" -TOKEN_PREFIX="$DEFAULT_TOKEN_PREFIX" -AUTH_HEADER_NAME="" -AUTH_HEADER_VALUE="" -TOKEN_ENV_NAMES=() -if [ -n "$DEFAULT_TOKEN_ENV_NAME" ]; then - TOKEN_ENV_NAMES=("$DEFAULT_TOKEN_ENV_NAME") -fi -PREFILL_SERVER_NAME="" -PREFILL_PROFILE_VALUE="" -PREFILL_COMMAND_PATH="" - -if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then - C_RESET="$(printf '\033[0m')" - C_BOLD="$(printf '\033[1m')" - C_DIM="$(printf '\033[2m')" - C_RED="$(printf '\033[31m')" - C_GREEN="$(printf '\033[32m')" - C_YELLOW="$(printf '\033[33m')" - C_BLUE="$(printf '\033[34m')" - C_MAGENTA="$(printf '\033[35m')" - C_CYAN="$(printf '\033[36m')" -else - C_RESET="" - C_BOLD="" - C_DIM="" - C_RED="" - C_GREEN="" - C_YELLOW="" - C_BLUE="" - C_MAGENTA="" - C_CYAN="" -fi - -ui_line() { - printf "%b%s%b\n" "$C_DIM" "------------------------------------------------------------" "$C_RESET" >&2 -} - -ui_title() { - printf "\n%b%s%b\n" "$C_BOLD$C_CYAN" "$1" "$C_RESET" >&2 -} - -ui_info() { - printf "%b[info]%b %s\n" "$C_BLUE" "$C_RESET" "$1" >&2 -} - -ui_success() { - printf "%b[ok]%b %s\n" "$C_GREEN" "$C_RESET" "$1" >&2 -} - -ui_warn() { - printf "%b[warn]%b %s\n" "$C_YELLOW" "$C_RESET" "$1" >&2 -} - -ui_error() { - printf "%b[error]%b %s\n" "$C_RED" "$C_RESET" "$1" >&2 -} - -prompt() { - local label="$1" - local default_value="$2" - local answer="" - - if [ -n "$default_value" ]; then - printf "%b%s%b [%s]: " "$C_BOLD" "$label" "$C_RESET" "$default_value" >&2 - else - printf "%b%s%b: " "$C_BOLD" "$label" "$C_RESET" >&2 - fi - - if [ -t 2 ] && [ -r /dev/tty ]; then - if ! IFS= read -r answer < /dev/tty 2>/dev/null; then - IFS= read -r answer || answer="" - fi - else - IFS= read -r answer || answer="" - fi - - if [ -z "$answer" ]; then - printf "%s" "$default_value" - return - fi - - printf "%s" "$answer" -} - -tty_prompt_available() { - [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ] -} - -menu_select() { - local title="$1" - shift - local options=("$@") - local count="${#options[@]}" - local index=0 - local key="" - local i=0 - local rows=$((count + 3)) - local rendered=0 - - if [ "$count" -eq 0 ]; then - return 1 - fi - - if ! tty_prompt_available; then - ui_title "$title" - i=0 - while [ "$i" -lt "$count" ]; do - printf " %d) %s\n" "$((i + 1))" "${options[$i]}" >&2 - i=$((i + 1)) - done - - while true; do - local raw_choice - raw_choice="$(prompt "Choix" "1")" - case "$raw_choice" in - ''|*[!0-9]*) - ui_warn "Choix invalide: $raw_choice" - ;; - *) - if [ "$raw_choice" -ge 1 ] && [ "$raw_choice" -le "$count" ]; then - printf "%s" "${options[$((raw_choice - 1))]}" - return 0 - fi - ui_warn "Choix invalide: $raw_choice" - ;; - esac - done - fi - - while true; do - if [ "$rendered" -eq 1 ]; then - printf "\033[%dA\033[J" "$rows" >&2 2>/dev/null || true - fi - ui_title "$title" - i=0 - while [ "$i" -lt "$count" ]; do - if [ "$i" -eq "$index" ]; then - printf " %b› %s%b\n" "$C_BOLD$C_CYAN" "${options[$i]}" "$C_RESET" >&2 - else - printf " %s\n" "${options[$i]}" >&2 - fi - i=$((i + 1)) - done - printf "%bUtilise ↑/↓ puis Entrée.%b\n" "$C_DIM" "$C_RESET" >&2 - rendered=1 - - if ! IFS= read -rsn1 key < /dev/tty; then - continue - fi - - case "$key" in - "") - printf "%s" "${options[$index]}" - return 0 - ;; - $'\x1b') - if IFS= read -rsn2 key < /dev/tty; then - case "$key" in - "[A") - if [ "$index" -eq 0 ]; then - index=$((count - 1)) - else - index=$((index - 1)) - fi - ;; - "[B") - if [ "$index" -eq $((count - 1)) ]; then - index=0 - else - index=$((index + 1)) - fi - ;; - esac - fi - ;; - esac - done -} - -sanitize_server_name() { - local raw="$1" - local sanitized - sanitized="$(printf "%s" "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g; s/--*/-/g; s/^-*//; s/-*$//')" - if [ -z "$sanitized" ]; then - sanitized="$BINARY_NAME" - fi - printf "%s" "$sanitized" -} - -toml_escape() { - printf "%s" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' -} - -trim_whitespace() { - printf "%s" "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' -} - -unquote_toml() { - local value - value="$(trim_whitespace "$1")" - if [ "${#value}" -ge 2 ]; then - case "$value" in - \"*\") - value="${value#\"}" - value="${value%\"}" - ;; - \'*\') - value="${value#\'}" - value="${value%\'}" - ;; - esac - fi - printf "%s" "$value" -} - -normalize_bool() { - local value - value="$(printf "%s" "$1" | tr '[:upper:]' '[:lower:]')" - value="$(trim_whitespace "$value")" - case "$value" in - true|1|yes|y) - printf "true" - ;; - false|0|no|n) - printf "false" - ;; - *) - printf "%s" "$2" - ;; - esac -} - -toml_read_update_value() { - local key="$1" - awk -v key="$key" ' - BEGIN { - in_update = 0 - } - { - line = $0 - sub(/[[:space:]]*#.*/, "", line) - if (line ~ /^[[:space:]]*\[/) { - if (line ~ /^[[:space:]]*\[update\][[:space:]]*$/) { - in_update = 1 - } else { - in_update = 0 - } - } - if (in_update == 1 && line ~ "^[[:space:]]*" key "[[:space:]]*=") { - sub("^[[:space:]]*" key "[[:space:]]*=[[:space:]]*", "", line) - print line - exit - } - } - ' mcp.toml -} - -toml_read_update_array() { - local key="$1" - local raw - raw="$(toml_read_update_value "$key")" - raw="$(trim_whitespace "$raw")" - if [ -z "$raw" ]; then - return - fi - - raw="${raw#\[}" - raw="${raw%\]}" - local items=() - local item="" - IFS=',' read -r -a items <<< "$raw" - for item in "${items[@]}"; do - item="$(unquote_toml "$item")" - item="$(trim_whitespace "$item")" - if [ -n "$item" ]; then - printf "%s\n" "$item" - fi - done -} - -normalize_release_config() { - RELEASE_DRIVER="$(printf "%s" "$RELEASE_DRIVER" | tr '[:upper:]' '[:lower:]')" - RELEASE_DRIVER="$(trim_whitespace "$RELEASE_DRIVER")" - - RELEASE_REPOSITORY="$(trim_whitespace "$RELEASE_REPOSITORY")" - RELEASE_REPOSITORY="${RELEASE_REPOSITORY#/}" - RELEASE_REPOSITORY="${RELEASE_REPOSITORY%/}" - - RELEASE_BASE_URL="$(trim_whitespace "$RELEASE_BASE_URL")" - RELEASE_BASE_URL="${RELEASE_BASE_URL%/}" - - LATEST_RELEASE_URL="$(trim_whitespace "$LATEST_RELEASE_URL")" - ASSET_NAME_TEMPLATE="$(trim_whitespace "$ASSET_NAME_TEMPLATE")" - CHECKSUM_ASSET_NAME="$(trim_whitespace "$CHECKSUM_ASSET_NAME")" - TOKEN_HEADER="$(trim_whitespace "$TOKEN_HEADER")" - TOKEN_PREFIX="$(trim_whitespace "$TOKEN_PREFIX")" - - if [ -z "$ASSET_NAME_TEMPLATE" ]; then - ASSET_NAME_TEMPLATE="$DEFAULT_ASSET_NAME_TEMPLATE" - fi - if [ -z "$CHECKSUM_ASSET_NAME" ]; then - CHECKSUM_ASSET_NAME="$DEFAULT_CHECKSUM_ASSET_NAME" - fi - - local filtered_env_names=() - local env_name="" - for env_name in "${TOKEN_ENV_NAMES[@]}"; do - env_name="$(trim_whitespace "$env_name")" - if [ -n "$env_name" ]; then - filtered_env_names+=("$env_name") - fi - done - TOKEN_ENV_NAMES=("${filtered_env_names[@]}") - - case "$RELEASE_DRIVER" in - gitea) - if [ -z "$TOKEN_HEADER" ]; then - TOKEN_HEADER="Authorization" - fi - if [ -z "$TOKEN_PREFIX" ]; then - TOKEN_PREFIX="token" - fi - if [ "${#TOKEN_ENV_NAMES[@]}" -eq 0 ]; then - TOKEN_ENV_NAMES=("GITEA_TOKEN") - fi - ;; - gitlab) - if [ -z "$RELEASE_BASE_URL" ]; then - RELEASE_BASE_URL="https://gitlab.com" - fi - if [ -z "$TOKEN_HEADER" ]; then - TOKEN_HEADER="PRIVATE-TOKEN" - fi - if [ "${#TOKEN_ENV_NAMES[@]}" -eq 0 ]; then - TOKEN_ENV_NAMES=("GITLAB_TOKEN" "GITLAB_PRIVATE_TOKEN") - fi - ;; - github) - if [ -z "$RELEASE_BASE_URL" ]; then - RELEASE_BASE_URL="https://api.github.com" - fi - if [ -z "$TOKEN_HEADER" ]; then - TOKEN_HEADER="Authorization" - fi - if [ -z "$TOKEN_PREFIX" ]; then - TOKEN_PREFIX="Bearer" - fi - if [ "${#TOKEN_ENV_NAMES[@]}" -eq 0 ]; then - TOKEN_ENV_NAMES=("GITHUB_TOKEN") - fi - ;; - esac -} - -load_release_config_from_manifest() { - if [ ! -f "mcp.toml" ]; then - ui_warn "mcp.toml introuvable: utilisation de la configuration release integree au script." - normalize_release_config - return - fi - - local value - value="$(toml_read_update_value "driver")" - if [ -n "$value" ]; then - RELEASE_DRIVER="$(printf "%s" "$(unquote_toml "$value")" | tr '[:upper:]' '[:lower:]')" - fi - - value="$(toml_read_update_value "repository")" - if [ -n "$value" ]; then - RELEASE_REPOSITORY="$(unquote_toml "$value")" - fi - - value="$(toml_read_update_value "base_url")" - if [ -n "$value" ]; then - RELEASE_BASE_URL="$(unquote_toml "$value")" - fi - - value="$(toml_read_update_value "latest_release_url")" - if [ -n "$value" ]; then - LATEST_RELEASE_URL="$(unquote_toml "$value")" - fi - - value="$(toml_read_update_value "asset_name_template")" - if [ -n "$value" ]; then - ASSET_NAME_TEMPLATE="$(unquote_toml "$value")" - fi - - value="$(toml_read_update_value "checksum_asset_name")" - if [ -n "$value" ]; then - CHECKSUM_ASSET_NAME="$(unquote_toml "$value")" - fi - - value="$(toml_read_update_value "checksum_required")" - if [ -n "$value" ]; then - CHECKSUM_REQUIRED="$(normalize_bool "$(unquote_toml "$value")" "$CHECKSUM_REQUIRED")" - fi - - value="$(toml_read_update_value "token_header")" - if [ -n "$value" ]; then - TOKEN_HEADER="$(unquote_toml "$value")" - fi - - value="$(toml_read_update_value "token_prefix")" - if [ -n "$value" ]; then - TOKEN_PREFIX="$(unquote_toml "$value")" - fi - - local parsed_token_env_names=() - local parsed_env_name="" - while IFS= read -r parsed_env_name; do - if [ -n "$parsed_env_name" ]; then - parsed_token_env_names+=("$parsed_env_name") - fi - done < <(toml_read_update_array "token_env_names") - if [ "${#parsed_token_env_names[@]}" -gt 0 ]; then - TOKEN_ENV_NAMES=("${parsed_token_env_names[@]}") - fi - - normalize_release_config -} - -resolve_goos() { - case "$(uname -s)" in - Linux) - printf "linux" - ;; - Darwin) - printf "darwin" - ;; - FreeBSD) - printf "freebsd" - ;; - OpenBSD) - printf "openbsd" - ;; - NetBSD) - printf "netbsd" - ;; - CYGWIN*|MINGW*|MSYS*) - printf "windows" - ;; - *) - ui_error "OS non supporte: $(uname -s)" - exit 1 - ;; - esac -} - -resolve_goarch() { - case "$(uname -m)" in - x86_64|amd64) - printf "amd64" - ;; - aarch64|arm64) - printf "arm64" - ;; - armv7*|armv6*|armhf) - printf "arm" - ;; - i386|i686) - printf "386" - ;; - ppc64le) - printf "ppc64le" - ;; - s390x) - printf "s390x" - ;; - riscv64) - printf "riscv64" - ;; - *) - ui_error "Architecture non supportee: $(uname -m)" - exit 1 - ;; - esac -} - -resolve_asset_name() { - local goos="$1" - local goarch="$2" - local ext="" - if [ "$goos" = "windows" ]; then - ext=".exe" - fi - - local template="$ASSET_NAME_TEMPLATE" - if [ -z "$template" ]; then - template="$DEFAULT_ASSET_NAME_TEMPLATE" - fi - - local asset_name="$template" - asset_name="${asset_name//\{binary\}/$BINARY_NAME}" - asset_name="${asset_name//\{os\}/$goos}" - asset_name="${asset_name//\{arch\}/$goarch}" - asset_name="${asset_name//\{ext\}/$ext}" - asset_name="$(trim_whitespace "$asset_name")" - - if [ -z "$asset_name" ]; then - ui_error "Le template d'asset resolve vers une valeur vide." - exit 1 - fi - - case "$asset_name" in - */*|*\\*) - ui_error "Nom d'asset invalide (separateur de chemin): $asset_name" - exit 1 - ;; - esac - - printf "%s" "$asset_name" -} - -resolve_latest_release_url() { - if [ -n "$LATEST_RELEASE_URL" ]; then - printf "%s" "$LATEST_RELEASE_URL" - return - fi - - if [ -z "$RELEASE_DRIVER" ]; then - ui_error "Configuration release incomplete: definir [update].driver ou [update].latest_release_url." - exit 1 - fi - - if [ -z "$RELEASE_REPOSITORY" ]; then - ui_error "Configuration release incomplete: definir [update].repository." - exit 1 - fi - - case "$RELEASE_DRIVER" in - gitea) - if [ -z "$RELEASE_BASE_URL" ]; then - ui_error "Configuration release incomplete: [update].base_url requis pour driver gitea." - exit 1 - fi - printf "%s/api/v1/repos/%s/releases/latest" "$RELEASE_BASE_URL" "$RELEASE_REPOSITORY" - ;; - gitlab) - local encoded_repository - encoded_repository="$(jq -nr --arg value "$RELEASE_REPOSITORY" '$value|@uri')" - printf "%s/api/v4/projects/%s/releases/permalink/latest" "$RELEASE_BASE_URL" "$encoded_repository" - ;; - github) - printf "%s/repos/%s/releases/latest" "$RELEASE_BASE_URL" "$RELEASE_REPOSITORY" - ;; - *) - ui_error "Driver release non supporte: $RELEASE_DRIVER (attendu: gitea, gitlab ou github)." - exit 1 - ;; - esac -} - -resolve_auth_header() { - AUTH_HEADER_NAME="" - AUTH_HEADER_VALUE="" - - local token="" - local env_name - for env_name in "${TOKEN_ENV_NAMES[@]}"; do - if [ -z "$env_name" ]; then - continue - fi - if [ -n "${!env_name:-}" ]; then - token="$(trim_whitespace "${!env_name}")" - if [ -n "$token" ]; then - break - fi - fi - done - - if [ -z "$token" ]; then - return - fi - - local header_name - header_name="$(trim_whitespace "$TOKEN_HEADER")" - if [ -z "$header_name" ]; then - return - fi - - local prefix - prefix="$(trim_whitespace "$TOKEN_PREFIX")" - if [ -n "$prefix" ]; then - local lower_token - local lower_prefix - lower_token="$(printf "%s" "$token" | tr '[:upper:]' '[:lower:]')" - lower_prefix="$(printf "%s" "$prefix" | tr '[:upper:]' '[:lower:]')" - if [[ "$lower_token" != "$lower_prefix"* ]]; then - token="$prefix $token" - fi - fi - - AUTH_HEADER_NAME="$header_name" - AUTH_HEADER_VALUE="$token" -} - -curl_download() { - local url="$1" - local output_path="$2" - local mode="${3:-}" - local curl_args=( - -fsSL - -H "User-Agent: mcp install wizard" - ) - - if [ "$mode" = "json" ]; then - curl_args+=(-H "Accept: application/json") - fi - if [ -n "$AUTH_HEADER_NAME" ] && [ -n "$AUTH_HEADER_VALUE" ]; then - curl_args+=(-H "${AUTH_HEADER_NAME}: ${AUTH_HEADER_VALUE}") - fi - - curl "${curl_args[@]}" "$url" -o "$output_path" -} - -resolve_url_reference() { - local value="$1" - local base="$2" - - if printf "%s" "$value" | grep -Eq '^https?://'; then - printf "%s" "$value" - return - fi - - local origin - origin="$(printf "%s" "$base" | sed -E 's#^(https?://[^/]+).*$#\1#')" - - if [ -z "$origin" ]; then - printf "%s" "$value" - return - fi - - if [ "${value#/}" != "$value" ]; then - printf "%s%s" "$origin" "$value" - return - fi - - local base_no_query - base_no_query="$(printf "%s" "$base" | sed 's/[?#].*$//')" - local base_dir="${base_no_query%/*}" - printf "%s/%s" "$base_dir" "$value" -} - -resolve_asset_url_from_release() { - local release_json_path="$1" - local asset_name="$2" - jq -r --arg asset "$asset_name" ' - ( - .assets.links[]? | select(.name == $asset) | (.direct_asset_url // .browser_download_url // .url // empty) - ), - ( - .assets[]? | select(.name == $asset) | (.direct_asset_url // .browser_download_url // .url // empty) - ) - | select(. != null and . != "") - ' "$release_json_path" | head -n 1 -} - -release_assets_preview() { - local release_json_path="$1" - jq -r ' - [ - (.assets.links[]?.name), - (.assets[]?.name) - ] - | map(select(. != null and . != "")) - | unique - | .[:8] - | join(", ") - ' "$release_json_path" -} - -parse_checksum_for_asset() { - local checksum_file="$1" - local asset_name="$2" - awk -v asset="$asset_name" ' - function is_hex(value) { - return value ~ /^[0-9A-Fa-f]{64}$/ - } - { - line = $0 - sub(/#.*/, "", line) - gsub(/^[[:space:]]+|[[:space:]]+$/, "", line) - if (line == "") { - next - } - - n = split(line, fields, /[[:space:]]+/) - if (n == 1 && is_hex(fields[1]) && fallback == "") { - fallback = fields[1] - next - } - - if (n >= 2 && is_hex(fields[1])) { - candidate = fields[2] - sub(/^\*/, "", candidate) - sub(/^\.\/+/, "", candidate) - if (candidate == asset) { - print fields[1] - exit - } - } - - if (n >= 2 && is_hex(fields[n])) { - candidate = fields[1] - sub(/^\*/, "", candidate) - sub(/^\.\/+/, "", candidate) - if (candidate == asset) { - print fields[n] - exit - } - } - } - END { - if (fallback != "") { - print fallback - } - } - ' "$checksum_file" -} - -sha256_file() { - local file_path="$1" - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$file_path" | awk '{print $1}' - return - fi - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$file_path" | awk '{print $1}' - return - fi - - ui_error "Aucun outil SHA-256 detecte (sha256sum ou shasum requis)." - exit 1 -} - -resolve_binary_path() { - if command -v "$BINARY_NAME" >/dev/null 2>&1; then - command -v "$BINARY_NAME" - return - fi - - if [ -x "$HOME/.local/bin/$BINARY_NAME" ]; then - printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" - return - fi - - printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" -} - -ensure_cli() { - local cli_name="$1" - if command -v "$cli_name" >/dev/null 2>&1; then - return - fi - - ui_error "Commande introuvable: $cli_name" - exit 1 -} - -install_binary() { - ensure_cli "curl" - ensure_cli "jq" - - load_release_config_from_manifest - resolve_auth_header - - local target_path="$HOME/.local/bin/$BINARY_NAME" - if command -v "$BINARY_NAME" >/dev/null 2>&1; then - target_path="$(command -v "$BINARY_NAME")" - ui_success "Binaire detecte: $target_path" - local reinstall - reinstall="$(prompt "Reinstaller depuis la derniere release ? (y/N)" "N")" - case "$reinstall" in - y|Y|yes|YES) - ;; - *) - return - ;; - esac - fi - - if [ ! -w "$(dirname "$target_path")" ]; then - ui_warn "Pas de droit d'ecriture dans $(dirname "$target_path"), installation dans $HOME/.local/bin." - target_path="$HOME/.local/bin/$BINARY_NAME" - fi - - local goos - goos="$(resolve_goos)" - local goarch - goarch="$(resolve_goarch)" - local asset_name - asset_name="$(resolve_asset_name "$goos" "$goarch")" - local release_url - release_url="$(resolve_latest_release_url)" - - ui_info "Recherche de la derniere release pour $RELEASE_REPOSITORY ($goos/$goarch)..." - local release_json - release_json="$(mktemp)" - if ! curl_download "$release_url" "$release_json" "json"; then - rm -f "$release_json" - ui_error "Impossible de recuperer la release latest depuis $release_url." - if [ "${#TOKEN_ENV_NAMES[@]}" -gt 0 ]; then - ui_info "Si le repo est prive, configure un token via: ${TOKEN_ENV_NAMES[*]}" - fi - exit 1 - fi - - local release_tag - release_tag="$(jq -r '.tag_name // empty' "$release_json")" - if [ -z "$release_tag" ]; then - rm -f "$release_json" - ui_error "Reponse release invalide: tag_name manquant." - exit 1 - fi - - local asset_url - asset_url="$(resolve_asset_url_from_release "$release_json" "$asset_name")" - if [ -z "$asset_url" ]; then - local preview - preview="$(release_assets_preview "$release_json")" - rm -f "$release_json" - ui_error "Aucun asset \"$asset_name\" dans la release $release_tag." - if [ -n "$preview" ]; then - ui_info "Assets disponibles: $preview" - fi - exit 1 - fi - asset_url="$(resolve_url_reference "$asset_url" "$release_url")" - - ui_info "Telechargement de l'asset $asset_name ($release_tag)..." - local download_path - download_path="$(mktemp)" - if ! curl_download "$asset_url" "$download_path"; then - rm -f "$release_json" "$download_path" - ui_error "Echec du telechargement de l'asset: $asset_url" - exit 1 - fi - - local checksum_asset_name - checksum_asset_name="${CHECKSUM_ASSET_NAME//\{asset\}/$asset_name}" - if [ -z "$checksum_asset_name" ]; then - checksum_asset_name="${asset_name}.sha256" - fi - local checksum_url - checksum_url="$(resolve_asset_url_from_release "$release_json" "$checksum_asset_name")" - if [ -n "$checksum_url" ]; then - checksum_url="$(resolve_url_reference "$checksum_url" "$release_url")" - local checksum_path - checksum_path="$(mktemp)" - if ! curl_download "$checksum_url" "$checksum_path"; then - rm -f "$release_json" "$download_path" "$checksum_path" - ui_error "Echec du telechargement du checksum: $checksum_url" - exit 1 - fi - - local expected_checksum - expected_checksum="$(parse_checksum_for_asset "$checksum_path" "$asset_name")" - if [ -z "$expected_checksum" ]; then - rm -f "$release_json" "$download_path" "$checksum_path" - ui_error "Checksum introuvable dans $checksum_asset_name pour l'asset $asset_name." - exit 1 - fi - - local actual_checksum - actual_checksum="$(sha256_file "$download_path")" - local actual_checksum_lc - actual_checksum_lc="$(printf "%s" "$actual_checksum" | tr '[:upper:]' '[:lower:]')" - local expected_checksum_lc - expected_checksum_lc="$(printf "%s" "$expected_checksum" | tr '[:upper:]' '[:lower:]')" - if [ "$actual_checksum_lc" != "$expected_checksum_lc" ]; then - rm -f "$release_json" "$download_path" "$checksum_path" - ui_error "Checksum invalide pour $asset_name." - exit 1 - fi - rm -f "$checksum_path" - ui_success "Checksum verifie pour $asset_name." - else - if [ "$CHECKSUM_REQUIRED" = "true" ]; then - rm -f "$release_json" "$download_path" - ui_error "Checksum requis mais asset \"$checksum_asset_name\" introuvable." - exit 1 - fi - ui_warn "Checksum non disponible pour $asset_name (verification ignoree)." - fi - - chmod +x "$download_path" - mkdir -p "$(dirname "$target_path")" - mv "$download_path" "$target_path" - rm -f "$release_json" - ui_success "Binaire installe: $target_path" - - if ! printf ":%s:" "$PATH" | grep -Fq ":$(dirname "$target_path"):"; then - ui_info "Ajoute $(dirname "$target_path") au PATH si necessaire." - fi -} - -run_setup_wizard() { - install_binary - - local profile - profile="$(prompt "Profil a configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")" - local binary_path - binary_path="$(resolve_binary_path)" - - ui_info "Lancement de $BINARY_NAME setup" - if [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then - env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty - else - env "${PROFILE_ENV}=${profile}" "$binary_path" setup - fi - ui_success "Setup termine pour le profil \"$profile\"." - - PREFILL_SERVER_NAME="$(sanitize_server_name "$BINARY_NAME")" - PREFILL_PROFILE_VALUE="$profile" - PREFILL_COMMAND_PATH="$binary_path" -} - -collect_server_inputs() { - local default_name - default_name="$(sanitize_server_name "${PREFILL_SERVER_NAME:-$BINARY_NAME}")" - SERVER_NAME="$(prompt "Nom du serveur MCP" "$default_name")" - SERVER_NAME="$(sanitize_server_name "$SERVER_NAME")" - - PROFILE_VALUE="$(prompt "Valeur de ${PROFILE_ENV}" "${PREFILL_PROFILE_VALUE:-$DEFAULT_PROFILE}")" - - local default_command - if [ -n "${PREFILL_COMMAND_PATH:-}" ]; then - default_command="$PREFILL_COMMAND_PATH" - else - default_command="$(resolve_binary_path)" - fi - COMMAND_PATH="$(prompt "Chemin du binaire serveur MCP" "$default_command")" -} - -choose_scope() { - local selected - selected="$(menu_select "Scope de configuration" "global (user)" "project (projet courant)")" - case "$selected" in - "global (user)") - printf "global" - ;; - *) - printf "project" - ;; - esac -} - -apply_claude_mcp() { - ensure_cli "claude" - collect_server_inputs - - local scope_choice - scope_choice="$(choose_scope)" - local claude_scope - if [ "$scope_choice" = "global" ]; then - claude_scope="user" - else - claude_scope="project" - fi - - ui_info "Application de la configuration Claude ($claude_scope)..." - claude mcp remove --scope "$claude_scope" "$SERVER_NAME" >/dev/null 2>&1 || true - claude mcp add \ - --transport stdio \ - --scope "$claude_scope" \ - "$SERVER_NAME" \ - --env "${PROFILE_ENV}=${PROFILE_VALUE}" \ - -- "$COMMAND_PATH" mcp - - ui_success "Serveur \"$SERVER_NAME\" configure dans Claude ($claude_scope)." -} - -rewrite_codex_project_config() { - local project_dir="$1" - local config_file="$project_dir/.codex/config.toml" - local section_prefix="[mcp_servers.${SERVER_NAME}" - - mkdir -p "$project_dir/.codex" - touch "$config_file" - - local tmp_file - tmp_file="$(mktemp)" - - awk -v prefix="$section_prefix" ' - function is_target(line, p, next_char) { - if (index(line, p) != 1) { - return 0 - } - next_char = substr(line, length(p) + 1, 1) - return next_char == "]" || next_char == "." - } - /^\[.*\]$/ { - if (is_target($0, prefix)) { - skip = 1 - next - } - if (skip == 1) { - skip = 0 - } - } - { - if (skip != 1) { - print $0 - } - } - ' "$config_file" > "$tmp_file" - - mv "$tmp_file" "$config_file" - - { - printf "\n[mcp_servers.%s]\n" "$SERVER_NAME" - printf "command = \"%s\"\n" "$(toml_escape "$COMMAND_PATH")" - printf "args = [\"mcp\"]\n\n" - printf "[mcp_servers.%s.env]\n" "$SERVER_NAME" - printf "%s = \"%s\"\n" "$PROFILE_ENV" "$(toml_escape "$PROFILE_VALUE")" - } >> "$config_file" -} - -apply_codex_mcp() { - ensure_cli "codex" - collect_server_inputs - - local scope_choice - scope_choice="$(choose_scope)" - - if [ "$scope_choice" = "global" ]; then - ui_info "Application via codex mcp add (scope global)..." - codex mcp remove "$SERVER_NAME" >/dev/null 2>&1 || true - codex mcp add \ - "$SERVER_NAME" \ - --env "${PROFILE_ENV}=${PROFILE_VALUE}" \ - -- "$COMMAND_PATH" mcp - ui_success "Serveur \"$SERVER_NAME\" configure dans le scope global Codex." - return - fi - - local default_project_dir - default_project_dir="$(pwd)" - local project_dir - project_dir="$(prompt "Dossier projet cible pour .codex/config.toml" "$default_project_dir")" - rewrite_codex_project_config "$project_dir" - ui_success "Configuration projet ecrite dans $project_dir/.codex/config.toml" -} - -print_mcp_json() { - collect_server_inputs - cat <&2 - printf "%bFramework module:%b %s\n" "$C_DIM" "$C_RESET" "$MODULE_PATH" >&2 - ui_line - printf "%bSelectionne une action dans le menu interactif.%b\n" "$C_DIM" "$C_RESET" >&2 -} - -post_setup_configure_mcp() { - ui_title "Configuration MCP apres setup" - local next_action - next_action="$(menu_select \ - "Configurer le MCP maintenant ?" \ - "Configurer Claude Code (apply direct)" \ - "Configurer Codex (apply direct)" \ - "Generer JSON MCP manuel" \ - "Terminer sans config MCP")" - printf "\n" >&2 - - case "$next_action" in - "Configurer Claude Code (apply direct)") - apply_claude_mcp - ;; - "Configurer Codex (apply direct)") - apply_codex_mcp - ;; - "Generer JSON MCP manuel") - ui_info "JSON MCP genere sur stdout." - print_mcp_json - ;; - *) - ui_info "Setup termine sans configuration MCP additionnelle." - ;; - esac -} - -main() { - print_header - - local action - action="$(menu_select \ - "Choisis une action" \ - "Installer/mettre a jour le binaire + setup" \ - "Configurer Claude Code (apply direct)" \ - "Configurer Codex (apply direct)" \ - "Generer JSON MCP manuel" \ - "Quitter")" - printf "\n" >&2 - - case "$action" in - "Installer/mettre a jour le binaire + setup") - run_setup_wizard - post_setup_configure_mcp - ;; - "Configurer Claude Code (apply direct)") - apply_claude_mcp - ;; - "Configurer Codex (apply direct)") - apply_codex_mcp - ;; - "Generer JSON MCP manuel") - ui_info "JSON MCP genere sur stdout." - print_mcp_json - ;; - *) - ui_warn "Annule." - ;; - esac -} - -main "$@" -` - -const mainTemplate = `package main - -import ( - "context" - "log" - "os" - - "{{.ModulePath}}/internal/app" -) - -var version = "dev" - -func main() { - if err := app.Run(context.Background(), os.Args[1:], version); err != nil { - log.Fatal(err) - } -} -` - -const appTemplate = `package app - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - - "gitea.lclr.dev/AI/mcp-framework/bootstrap" - "gitea.lclr.dev/AI/mcp-framework/cli" - "gitea.lclr.dev/AI/mcp-framework/config" - "gitea.lclr.dev/AI/mcp-framework/manifest" - "gitea.lclr.dev/AI/mcp-framework/secretstore" - "gitea.lclr.dev/AI/mcp-framework/update" -) - -var embeddedManifest = ` + "`" + `binary_name = "{{.BinaryName}}" -docs_url = "{{.DocsURL}}" - -[update] -source_name = "Release endpoint" -driver = "{{.ReleaseDriver}}" -repository = "{{.ReleaseRepository}}" -base_url = "{{.ReleaseBaseURL}}" -asset_name_template = "{binary}-{os}-{arch}{ext}" -checksum_asset_name = "{asset}.sha256" -checksum_required = true -signature_asset_name = "{asset}.sig" -signature_required = false -signature_public_key_env_names = ["{{.ReleasePublicKeyEnv}}"] -token_header = "Authorization" -token_prefix = "token" -token_env_names = ["{{.ReleaseTokenEnv}}"] - -[environment] -known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] - -[secret_store] -backend_policy = "{{.SecretStorePolicy}}" - -[profiles] -default = "{{.DefaultProfile}}" -known = [{{- range $index, $value := .Profiles}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] - -[bootstrap] -description = "{{.Description}}" -` + "`" + ` - -type Profile struct { - BaseURL string -} - -type Runtime struct { - ConfigStore config.Store[Profile] - Manifest manifest.File - ManifestSource string - BinaryName string - Description string - Version string - DefaultProfile string - ProfileEnv string - TokenEnv string - SecretName string -} - -const ( - ansiRedColor = "\033[31m" - ansiResetColor = "\033[0m" -) - -func Run(ctx context.Context, args []string, version string) error { - runtime, err := NewRuntime(version) - if err != nil { - return err - } - - return runtime.Run(ctx, args) -} - -func NewRuntime(version string) (Runtime, error) { - manifestStartDir := "." - if executablePath, err := os.Executable(); err == nil { - if dir := strings.TrimSpace(filepath.Dir(executablePath)); dir != "" { - manifestStartDir = dir - } - } - - manifestFile, manifestSource, err := manifest.LoadDefaultOrEmbedded(manifestStartDir, embeddedManifest) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - return Runtime{}, err - } - manifestFile = manifest.File{} - manifestSource = "" - } - - bootstrapInfo := manifestFile.BootstrapInfo() - scaffoldInfo := manifestFile.ScaffoldInfo() - - binaryName := firstNonEmpty(bootstrapInfo.BinaryName, "{{.BinaryName}}") - description := firstNonEmpty(bootstrapInfo.Description, "{{.Description}}") - defaultProfile := firstNonEmpty(scaffoldInfo.DefaultProfile, "{{.DefaultProfile}}") - profileEnv := "{{.ProfileEnv}}" - tokenEnv := "{{.TokenEnv}}" - if len(scaffoldInfo.KnownEnvironmentVariables) > 0 { - profileEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[0], profileEnv) - } - if len(scaffoldInfo.KnownEnvironmentVariables) > 2 { - tokenEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[2], tokenEnv) - } - - return Runtime{ - ConfigStore: config.NewStore[Profile](binaryName), - Manifest: manifestFile, - ManifestSource: manifestSource, - BinaryName: binaryName, - Description: description, - Version: firstNonEmpty(strings.TrimSpace(version), "dev"), - DefaultProfile: defaultProfile, - ProfileEnv: profileEnv, - TokenEnv: tokenEnv, - SecretName: binaryName + "-api-token", - }, nil -} - -func (r Runtime) Run(ctx context.Context, args []string) error { - return bootstrap.Run(ctx, bootstrap.Options{ - BinaryName: r.BinaryName, - Description: r.Description, - Version: r.Version, - Args: args, - Hooks: bootstrap.Hooks{ - Setup: r.runSetup, - Login: r.runLogin, - MCP: r.runMCP, - ConfigShow: r.runConfigShow, - ConfigTest: r.runConfigTest, - Update: r.runUpdate, - }, - }) -} - -func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { - stdin, ok := inv.Stdin.(*os.File) - if !ok || stdin == nil { - stdin = os.Stdin - } - - stdout := inv.Stdout - if stdout == nil { - stdout = os.Stdout - } - - if _, err := r.openSecretStore(); err != nil { - return fmt.Errorf("secret backend is not ready: %w", err) - } - - cfg, _, err := r.ConfigStore.LoadDefault() - if err != nil { - return err - } - - profileName := r.resolveProfileName(cfg.CurrentProfile) - profile := cfg.Profiles[profileName] - storedToken, err := r.readToken() - switch { - case err == nil: - case errors.Is(err, secretstore.ErrNotFound): - storedToken = "" - default: - return err - } - - result, err := cli.RunSetup(cli.SetupOptions{ - Stdin: stdin, - Stdout: stdout, - Fields: []cli.SetupField{ - { - Name: "base_url", - Label: "Base URL", - Type: cli.SetupFieldURL, - Required: true, - Default: profile.BaseURL, - }, - { - Name: "api_token", - Label: "API token", - Type: cli.SetupFieldSecret, - Required: true, - ExistingSecret: storedToken, - }, - }, - }) - if err != nil { - return err - } - - baseURLValue, _ := result.Get("base_url") - tokenValue, _ := result.Get("api_token") - - profile.BaseURL = strings.TrimSpace(baseURLValue.String) - cfg.CurrentProfile = profileName - cfg.Profiles[profileName] = profile - if _, err := r.ConfigStore.SaveDefault(cfg); err != nil { - return err - } - - store, err := r.openSecretStore() - if err != nil { - return err - } - - if err := cli.WriteSetupSecretVerified(cli.SetupSecretWriteOptions{ - Store: store, - SecretName: r.SecretName, - SecretLabel: "API token", - TokenEnv: r.TokenEnv, - Value: tokenValue, - }); err != nil { - return err - } - - _, err = fmt.Fprintf(stdout, "Configuration saved for profile %q. Secret readability confirmed.\n", profileName) - return err -} - -func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error { - if r.activeBackendPolicy() != secretstore.BackendBitwardenCLI { - return fmt.Errorf( - "commande login disponible uniquement avec secret_store.backend_policy=%q", - secretstore.BackendBitwardenCLI, - ) - } - - stdin := inv.Stdin - if stdin == nil { - stdin = os.Stdin - } - - stdout := inv.Stdout - if stdout == nil { - stdout = os.Stdout - } - - stderr := inv.Stderr - if stderr == nil { - stderr = os.Stderr - } - - if _, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ - ServiceName: r.BinaryName, - Stdin: stdin, - Stdout: stdout, - Stderr: stderr, - }); err != nil { - return err - } - - _, err := fmt.Fprintf(stdout, "Session Bitwarden persistée pour %q.\n", r.BinaryName) - return err -} - -func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error { - stdout := inv.Stdout - if stdout == nil { - stdout = os.Stdout - } - - cfg, _, err := r.ConfigStore.LoadDefault() - if err != nil { - return err - } - - profileName := r.resolveProfileName(cfg.CurrentProfile) - profile, ok := cfg.Profiles[profileName] - if !ok { - return fmt.Errorf("profil %q absent, lance %s setup", profileName, r.BinaryName) - } - - token, err := r.readToken() - if err != nil { - if errors.Is(err, secretstore.ErrNotFound) { - return fmt.Errorf("secret %q introuvable, lance %s setup", r.SecretName, r.BinaryName) - } - return err - } - - fmt.Fprintf(stdout, "MCP prêt sur %s (profil %s).\n", profile.BaseURL, profileName) - fmt.Fprintf(stdout, "Token chargé (%d caractères).\n", len(strings.TrimSpace(token))) - fmt.Fprintln(stdout, "Ajoute ici ta logique métier MCP.") - return nil -} - -func (r Runtime) runConfigShow(_ context.Context, inv bootstrap.Invocation) error { - stdout := inv.Stdout - if stdout == nil { - stdout = os.Stdout - } - - cfg, path, err := r.ConfigStore.LoadDefault() - if err != nil { - return err - } - - payload, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return fmt.Errorf("encode config JSON: %w", err) - } - - if _, err := fmt.Fprintf(stdout, "Config: %s\n", path); err != nil { - return err - } - if _, err := fmt.Fprintf(stdout, "Manifest source: %s\n", r.manifestSourceLabel()); err != nil { - return err - } - if _, err := fmt.Fprintf(stdout, "Secret backend policy (active): %s\n", r.activeBackendPolicy()); err != nil { - return err - } - _, err = fmt.Fprintf(stdout, "%s\n", payload) - return err -} - -func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) error { - stdout := inv.Stdout - if stdout == nil { - stdout = os.Stdout - } - - secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore) - - report := cli.RunDoctor(ctx, cli.DoctorOptions{ - ConfigCheck: cli.NewConfigCheck(r.ConfigStore), - SecretStoreCheck: cli.SecretStoreAvailabilityCheck(secretStoreFactory), - SecretBackendPolicy: r.activeBackendPolicy(), - RequiredSecrets: []cli.DoctorSecret{ - {Name: r.SecretName, Label: "API token"}, - }, - SecretStoreFactory: secretStoreFactory, - ManifestCheck: r.manifestDoctorCheck(), - BitwardenOptions: cli.BitwardenDoctorOptions{ - LookupEnv: os.LookupEnv, - }, - }) - - if err := cli.RenderDoctorReport(stdout, report); err != nil { - return err - } - - if report.HasFailures() { - return errors.New("doctor checks failed") - } - - return nil -} - -func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) { - if factory == nil { - return nil - } - - var ( - once sync.Once - store secretstore.Store - err error - ) - - return func() (secretstore.Store, error) { - once.Do(func() { - store, err = factory() - }) - return store, err - } -} - -func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error { - stdout := inv.Stdout - if stdout == nil { - stdout = os.Stdout - } - - return update.Run(ctx, update.Options{ - CurrentVersion: r.Version, - BinaryName: r.BinaryName, - ReleaseSource: r.Manifest.Update.ReleaseSource(), - Stdout: stdout, - }) -} - -func (r Runtime) openSecretStore() (secretstore.Store, error) { - policy := r.activeBackendPolicy() - if policy == secretstore.BackendBitwardenCLI { - if err := r.ensureBitwardenSession(); err != nil { - return nil, err - } - } - - return secretstore.Open(secretstore.Options{ - ServiceName: r.BinaryName, - BackendPolicy: policy, - LookupEnv: func(name string) (string, bool) { - if name == r.SecretName { - return os.LookupEnv(r.TokenEnv) - } - return os.LookupEnv(name) - }, - }) -} - -func (r Runtime) ensureBitwardenSession() error { - if hasBitwardenSessionInEnv() { - return nil - } - - loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ - ServiceName: r.BinaryName, - }) - if err != nil { - return err - } - - if loaded || hasBitwardenSessionInEnv() { - return nil - } - - return errors.New(colorizeRed(fmt.Sprintf( - "Session Bitwarden introuvable. Lance %s login puis relance la commande.", - r.BinaryName, - ))) -} - -func hasBitwardenSessionInEnv() bool { - session, ok := os.LookupEnv("BW_SESSION") - return ok && strings.TrimSpace(session) != "" -} - -func colorizeRed(message string) string { - return ansiRedColor + strings.TrimSpace(message) + ansiResetColor -} - -func (r Runtime) activeBackendPolicy() secretstore.BackendPolicy { - policy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy)) - if policy == "" { - return secretstore.BackendAuto - } - return policy -} - -func (r Runtime) manifestSourceLabel() string { - source := strings.TrimSpace(r.ManifestSource) - if source == "" { - return "embedded defaults" - } - return source -} - -func (r Runtime) readToken() (string, error) { - store, err := r.openSecretStore() - if err != nil { - return "", err - } - - return store.GetSecret(r.SecretName) -} - -func (r Runtime) resolveProfileName(currentProfile string) string { - resolved := cli.ResolveProfileName("", os.Getenv(r.ProfileEnv), currentProfile) - if strings.TrimSpace(resolved) != "" { - return resolved - } - return r.DefaultProfile -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed != "" { - return trimmed - } - } - return "" -} - -func (r Runtime) manifestDoctorCheck() cli.DoctorCheck { - return func(context.Context) cli.DoctorResult { - if strings.TrimSpace(r.ManifestSource) == "" { - return cli.DoctorResult{ - Name: "manifest", - Status: cli.DoctorStatusWarn, - Summary: "manifest is missing, using built-in defaults", - Detail: fmt.Sprintf("source=%s policy=%s", r.manifestSourceLabel(), r.activeBackendPolicy()), - } - } - - return cli.DoctorResult{ - Name: "manifest", - Status: cli.DoctorStatusOK, - Summary: "manifest is valid", - Detail: fmt.Sprintf("source=%s policy=%s", r.manifestSourceLabel(), r.activeBackendPolicy()), - } - } -} -` - -const manifestTemplate = `binary_name = "{{.BinaryName}}" -docs_url = "{{.DocsURL}}" - -[update] -source_name = "Release endpoint" -driver = "{{.ReleaseDriver}}" -repository = "{{.ReleaseRepository}}" -base_url = "{{.ReleaseBaseURL}}" -asset_name_template = "{binary}-{os}-{arch}{ext}" -checksum_asset_name = "{asset}.sha256" -checksum_required = true -signature_asset_name = "{asset}.sig" -signature_required = false -signature_public_key_env_names = ["{{.ReleasePublicKeyEnv}}"] -token_header = "Authorization" -token_prefix = "token" -token_env_names = ["{{.ReleaseTokenEnv}}"] - -[environment] -known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] - -[secret_store] -backend_policy = "{{.SecretStorePolicy}}" - -[profiles] -default = "{{.DefaultProfile}}" -known = [{{- range $index, $value := .Profiles}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] - -[bootstrap] -description = "{{.Description}}" -` - -const readmeTemplate = `# {{.BinaryName}} - -Binaire MCP généré depuis ` + "`mcp-framework`" + `. - -## Arborescence générée - -` + "```text" + ` -. -├── cmd/ -│ └── {{.BinaryName}}/ -│ └── main.go -├── internal/ -│ └── app/ -│ └── app.go -├── .gitignore -├── go.mod -├── install.sh -├── mcp.toml -└── README.md -` + "```" + ` - -## Démarrage rapide - -1. Installer les dépendances : - -` + "```bash" + ` -go mod tidy -` + "```" + ` - -2. Vérifier l’aide CLI bootstrap : - -` + "```bash" + ` -go run ./cmd/{{.BinaryName}} help -` + "```" + ` - -3. Initialiser la configuration locale : - -` + "```bash" + ` -go run ./cmd/{{.BinaryName}} setup -` + "```" + ` - -4. Lancer le flux MCP (placeholder) : - -` + "```bash" + ` -go run ./cmd/{{.BinaryName}} mcp -` + "```" + ` - -5. Vérifier la configuration et le manifeste : - -` + "```bash" + ` -go run ./cmd/{{.BinaryName}} config test -` + "```" + ` - -6. Publier un install wizard consommable via ` + "`curl | bash`" + ` : - -` + "```bash" + ` -curl -fsSL https://///raw/branch/main/install.sh | bash -` + "```" + ` - -Le wizard permet ensuite d'appliquer directement la configuration MCP pour Claude Code ou Codex (scope global/projet), ou de générer un JSON manuel. - -## Points à adapter - -- Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs). -- Adapter l'URL ` + "`curl .../install.sh`" + ` à votre forge/répertoire. -- Compléter la logique métier dans ` + "`internal/app/app.go`" + ` (` + "`runMCP`" + `). -- Ajuster les variables d’environnement connues si besoin. -` diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go deleted file mode 100644 index bd32a49..0000000 --- a/scaffold/scaffold_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package scaffold - -import ( - "errors" - "go/parser" - "go/token" - "os" - "path/filepath" - "slices" - "strings" - "testing" -) - -func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { - target := filepath.Join(t.TempDir(), "my-mcp") - - result, err := Generate(Options{ - TargetDir: target, - ModulePath: "example.com/acme/my-mcp", - BinaryName: "my-mcp", - Description: "Client MCP interne", - DefaultProfile: "prod", - Profiles: []string{"dev", "prod"}, - }) - if err != nil { - t.Fatalf("Generate returned error: %v", err) - } - - if result.Root != target { - t.Fatalf("result root = %q, want %q", result.Root, target) - } - - wantFiles := []string{ - ".gitignore", - "README.md", - "cmd/my-mcp/main.go", - "go.mod", - "install.sh", - "internal/app/app.go", - "mcp.toml", - } - if !slices.Equal(result.Files, wantFiles) { - t.Fatalf("result files = %v, want %v", result.Files, wantFiles) - } - - for _, path := range wantFiles { - if _, err := os.Stat(filepath.Join(target, filepath.FromSlash(path))); err != nil { - t.Fatalf("generated file %q missing: %v", path, err) - } - } - - mainGo, err := os.ReadFile(filepath.Join(target, "cmd", "my-mcp", "main.go")) - if err != nil { - t.Fatalf("ReadFile main.go: %v", err) - } - if !strings.Contains(string(mainGo), "\"example.com/acme/my-mcp/internal/app\"") { - t.Fatalf("main.go does not import internal app package") - } - if _, err := parser.ParseFile(token.NewFileSet(), "main.go", mainGo, parser.AllErrors); err != nil { - t.Fatalf("generated main.go is invalid Go: %v", err) - } - - appGo, err := os.ReadFile(filepath.Join(target, "internal", "app", "app.go")) - if err != nil { - t.Fatalf("ReadFile app.go: %v", err) - } - for _, snippet := range []string{ - "config.NewStore[Profile]", - "secretstore.Open(secretstore.Options", - "secretstore.EnsureBitwardenSessionEnv", - "func (r Runtime) ensureBitwardenSession() error {", - "\\033[31m", - "secretstore.LoginBitwarden", - "update.Run", - "manifest.LoadDefaultOrEmbedded", - "bootstrap.Run", - "os.Executable()", - "errors.Is(err, os.ErrNotExist)", - `var embeddedManifest = `, - "ManifestSource", - "ManifestCheck: r.manifestDoctorCheck()", - "SecretBackendPolicy: r.activeBackendPolicy()", - "Login: r.runLogin,", - "func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error {", - "secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)", - "func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {", - "cli.WriteSetupSecretVerified", - } { - if !strings.Contains(string(appGo), snippet) { - t.Fatalf("app.go missing snippet %q", snippet) - } - } - if _, err := parser.ParseFile(token.NewFileSet(), "app.go", appGo, parser.AllErrors); err != nil { - t.Fatalf("generated app.go is invalid Go: %v", err) - } - - manifestContent, err := os.ReadFile(filepath.Join(target, "mcp.toml")) - if err != nil { - t.Fatalf("ReadFile mcp.toml: %v", err) - } - for _, snippet := range []string{ - "binary_name = \"my-mcp\"", - "[update]", - "checksum_required = true", - "signature_asset_name = \"{asset}.sig\"", - "signature_required = false", - "[secret_store]", - "[environment]", - "[profiles]", - "backend_policy = \"auto\"", - } { - if !strings.Contains(string(manifestContent), snippet) { - t.Fatalf("mcp.toml missing snippet %q", snippet) - } - } - - readme, err := os.ReadFile(filepath.Join(target, "README.md")) - if err != nil { - t.Fatalf("ReadFile README.md: %v", err) - } - for _, snippet := range []string{ - "Arborescence générée", - "go run ./cmd/my-mcp setup", - "curl -fsSL https://///raw/branch/main/install.sh | bash", - "internal/app/app.go", - } { - if !strings.Contains(string(readme), snippet) { - t.Fatalf("README missing snippet %q", snippet) - } - } - - installScriptPath := filepath.Join(target, "install.sh") - installScript, err := os.ReadFile(installScriptPath) - if err != nil { - t.Fatalf("ReadFile install.sh: %v", err) - } - for _, snippet := range []string{ - "#!/usr/bin/env bash", - `MODULE_PATH="example.com/acme/my-mcp"`, - `DEFAULT_RELEASE_REPOSITORY="org/my-mcp"`, - `load_release_config_from_manifest`, - `resolve_latest_release_url()`, - `curl_download "$release_url" "$release_json" "json"`, - `asset_name="$(resolve_asset_name "$goos" "$goarch")"`, - `Reinstaller depuis la derniere release ? (y/N)`, - "MCP Install Wizard", - `menu_select() {`, - `Utilise ↑/↓ puis Entrée.`, - `Configurer le MCP maintenant ?`, - `claude mcp add \`, - `--transport stdio \`, - `--scope "$claude_scope" \`, - `--env "${PROFILE_ENV}=${PROFILE_VALUE}" \`, - `codex mcp add \`, - `Dossier projet cible pour .codex/config.toml`, - `[mcp_servers.%s]`, - `"${PROFILE_ENV}=${PROFILE_VALUE}"`, - } { - if !strings.Contains(string(installScript), snippet) { - t.Fatalf("install.sh missing snippet %q", snippet) - } - } - - info, err := os.Stat(installScriptPath) - if err != nil { - t.Fatalf("Stat install.sh: %v", err) - } - if info.Mode().Perm() != 0o755 { - t.Fatalf("install.sh mode = %o, want 755", info.Mode().Perm()) - } -} - -func TestGenerateUsesDefaultsFromTargetDirectory(t *testing.T) { - target := filepath.Join(t.TempDir(), "super-agent-mcp") - - _, err := Generate(Options{TargetDir: target}) - if err != nil { - t.Fatalf("Generate returned error: %v", err) - } - - goModContent, err := os.ReadFile(filepath.Join(target, "go.mod")) - if err != nil { - t.Fatalf("ReadFile go.mod: %v", err) - } - if !strings.Contains(string(goModContent), "module example.com/super-agent-mcp") { - t.Fatalf("go.mod should contain default module path") - } - - manifestContent, err := os.ReadFile(filepath.Join(target, "mcp.toml")) - if err != nil { - t.Fatalf("ReadFile mcp.toml: %v", err) - } - for _, snippet := range []string{ - "binary_name = \"super-agent-mcp\"", - "SUPER_AGENT_MCP_PROFILE", - "SUPER_AGENT_MCP_API_TOKEN", - } { - if !strings.Contains(string(manifestContent), snippet) { - t.Fatalf("mcp.toml missing snippet %q", snippet) - } - } -} - -func TestGenerateFailsWhenFileAlreadyExistsWithoutOverwrite(t *testing.T) { - target := t.TempDir() - readmePath := filepath.Join(target, "README.md") - if err := os.WriteFile(readmePath, []byte("pre-existing"), 0o644); err != nil { - t.Fatalf("WriteFile README.md: %v", err) - } - - _, err := Generate(Options{TargetDir: target}) - if !errors.Is(err, ErrFileExists) { - t.Fatalf("Generate error = %v, want ErrFileExists", err) - } -} - -func TestGenerateOverwritesExistingFilesWhenRequested(t *testing.T) { - target := t.TempDir() - readmePath := filepath.Join(target, "README.md") - if err := os.WriteFile(readmePath, []byte("pre-existing"), 0o644); err != nil { - t.Fatalf("WriteFile README.md: %v", err) - } - - _, err := Generate(Options{TargetDir: target, Overwrite: true}) - if err != nil { - t.Fatalf("Generate returned error: %v", err) - } - - readmeContent, err := os.ReadFile(readmePath) - if err != nil { - t.Fatalf("ReadFile README.md: %v", err) - } - if !strings.Contains(string(readmeContent), "Démarrage rapide") { - t.Fatalf("README should be overwritten with scaffold content") - } -} - -func TestGenerateRequiresTargetDirectory(t *testing.T) { - _, err := Generate(Options{}) - if !errors.Is(err, ErrTargetDirRequired) { - t.Fatalf("Generate error = %v, want ErrTargetDirRequired", err) - } -} diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go deleted file mode 100644 index 9c580b8..0000000 --- a/secretstore/bitwarden.go +++ /dev/null @@ -1,927 +0,0 @@ -package secretstore - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" - "sync/atomic" - "time" -) - -const ( - defaultBitwardenCommand = "bw" - bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG" - bitwardenSessionEnvName = "BW_SESSION" - bitwardenSecretFieldName = "mcp-secret" - bitwardenServiceFieldName = "mcp-service" - bitwardenSecretNameFieldName = "mcp-secret-name" - bitwardenLoaderMessage = "Waiting BitWarden..." - bitwardenLoaderInterval = 90 * time.Millisecond - bitwardenLoaderColorBase = "\033[38;5;39m" - bitwardenLoaderColorWave = "\033[38;5;81m" - bitwardenLoaderColorFocus = "\033[38;5;117m" - bitwardenLoaderResetColor = "\033[0m" - bitwardenLoaderClearLine = "\r\033[2K" -) - -type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error) -type bitwardenInteractiveRunner func( - command string, - stdin io.Reader, - stdout, stderr io.Writer, - args ...string, -) ([]byte, error) - -var runBitwardenCLI bitwardenRunner = executeBitwardenCLI -var runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive -var startBitwardenLoaderFunc = startBitwardenLoader -var bitwardenLoaderActive atomic.Bool -var bitwardenDebugOutput io.Writer = os.Stderr - -type bitwardenStore struct { - command string - serviceName string - debug bool - lookupEnv func(string) (string, bool) - shell string - cache *bitwardenCache -} - -type bitwardenListItem struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type bitwardenStatusOutput struct { - Status string `json:"status"` -} - -func newBitwardenStore(options Options, policy BackendPolicy, serviceName string) (Store, error) { - command := strings.TrimSpace(options.BitwardenCommand) - if command == "" { - command = defaultBitwardenCommand - } - debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) - - if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ - ServiceName: serviceName, - }); err != nil { - return nil, fmt.Errorf( - "secret backend policy %q cannot load persisted bitwarden session for service %q: %w", - policy, - serviceName, - errors.Join(ErrBackendUnavailable, err), - ) - } - - session, _ := os.LookupEnv(bitwardenSessionEnvName) - cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv() - store := &bitwardenStore{ - command: command, - serviceName: serviceName, - debug: debugEnabled, - lookupEnv: options.LookupEnv, - shell: options.Shell, - cache: newBitwardenCache(bitwardenCacheOptions{ - ServiceName: serviceName, - Session: session, - TTL: defaultBitwardenCacheTTL, - CacheDir: resolveBitwardenCacheDir(serviceName), - Enabled: cacheEnabled, - }), - } - - return store, nil -} - -func verifyBitwardenCLIReady(options Options) error { - command := strings.TrimSpace(options.BitwardenCommand) - if command == "" { - command = defaultBitwardenCommand - } - debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) - - if _, err := runBitwardenCommand(command, debugEnabled, nil, "--version"); err != nil { - if errors.Is(err, exec.ErrNotFound) { - return fmt.Errorf( - "requires bitwarden CLI command %q in PATH: %w", - command, - errors.Join(ErrBackendUnavailable, err), - ) - } - - return fmt.Errorf( - "cannot verify bitwarden CLI command %q: %w", - command, - errors.Join(ErrBackendUnavailable, err), - ) - } - - if err := EnsureBitwardenReady(options); err != nil { - return fmt.Errorf( - "cannot use bitwarden CLI command %q right now: %w", - command, - errors.Join(ErrBackendUnavailable, err), - ) - } - - return nil -} - -func EnsureBitwardenReady(options Options) error { - command := strings.TrimSpace(options.BitwardenCommand) - if command == "" { - command = defaultBitwardenCommand - } - debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) - unlockCommand := bitwardenUnlockRemediation(command, options.Shell) - - status, err := readBitwardenStatus(command, debugEnabled) - if err != nil { - return err - } - - switch status { - case "unauthenticated": - return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn) - case "locked": - return fmt.Errorf( - "%w: run `%s` then retry", - ErrBWLocked, - unlockCommand, - ) - case "unlocked": - lookupEnv := options.LookupEnv - if lookupEnv == nil { - lookupEnv = os.LookupEnv - } - - session, ok := lookupEnv(bitwardenSessionEnvName) - if !ok || strings.TrimSpace(session) == "" { - session, ok = os.LookupEnv(bitwardenSessionEnvName) - } - if !ok || strings.TrimSpace(session) == "" { - return fmt.Errorf( - "%w: environment variable %q is missing; run `%s` then retry", - ErrBWLocked, - bitwardenSessionEnvName, - unlockCommand, - ) - } - return nil - default: - return fmt.Errorf( - "%w: unsupported bitwarden status %q", - ErrBWUnavailable, - status, - ) - } -} - -func readBitwardenStatus(command string, debug bool) (string, error) { - output, err := runBitwardenCommand(command, debug, nil, "status") - if err != nil { - return "", fmt.Errorf("check bitwarden CLI status: %w", err) - } - - trimmed := strings.TrimSpace(string(output)) - if trimmed == "" { - return "", fmt.Errorf("%w: bitwarden CLI returned an empty status", ErrBWUnavailable) - } - - var status bitwardenStatusOutput - if err := json.Unmarshal([]byte(trimmed), &status); err != nil { - return "", fmt.Errorf("decode bitwarden CLI status: %w", errors.Join(ErrBWUnavailable, err)) - } - - return strings.ToLower(strings.TrimSpace(status.Status)), nil -} - -func bitwardenUnlockRemediation(command, shellHint string) string { - unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command)) - - switch detectShellFlavor(shellHint) { - case "fish": - return fmt.Sprintf("set -x %s (%s)", bitwardenSessionEnvName, unlockCommand) - case "powershell": - return fmt.Sprintf("$env:%s = (%s)", bitwardenSessionEnvName, unlockCommand) - case "cmd": - return fmt.Sprintf( - "for /f \"usebackq delims=\" %%i in (`%s`) do set %s=%%i", - unlockCommand, - bitwardenSessionEnvName, - ) - default: - return fmt.Sprintf("export %s=\"$(%s)\"", bitwardenSessionEnvName, unlockCommand) - } -} - -func detectShellFlavor(shellHint string) string { - raw := strings.TrimSpace(shellHint) - if raw == "" { - raw = strings.TrimSpace(os.Getenv("SHELL")) - } - if raw == "" { - raw = strings.TrimSpace(os.Getenv("COMSPEC")) - } - if raw == "" && runtime.GOOS == "windows" { - return "powershell" - } - - lower := strings.ToLower(strings.TrimSpace(raw)) - base := strings.ToLower(filepath.Base(lower)) - - switch { - case strings.Contains(lower, "powershell"), - strings.Contains(lower, "pwsh"), - base == "powershell", - base == "powershell.exe", - base == "pwsh", - base == "pwsh.exe": - return "powershell" - case strings.Contains(lower, "fish"), base == "fish": - return "fish" - case strings.Contains(lower, "cmd.exe"), base == "cmd", base == "cmd.exe": - return "cmd" - default: - return "posix" - } -} - -func (s *bitwardenStore) SetSecret(name, label, secret string) error { - secretName := s.scopedName(name) - if err := s.ensureReady(); err != nil { - return fmt.Errorf("prepare bitwarden CLI for saving secret %q: %w", name, err) - } - - item, payload, err := s.findItem(secretName, name) - switch { - case errors.Is(err, ErrNotFound): - template, err := s.itemTemplate() - if err != nil { - return err - } - - setBitwardenSecretPayload(template, s.serviceName, name, secretName, label, secret) - encoded, err := s.encodePayload(template) - if err != nil { - return err - } - - if _, err := s.execute( - fmt.Sprintf("create bitwarden item for secret %q", name), - nil, - "create", - "item", - encoded, - ); err != nil { - return err - } - if s.cache != nil { - s.cache.invalidate(name, secretName) - s.cache.store(name, secretName, secret) - } - return nil - case err != nil: - return err - } - - setBitwardenSecretPayload(payload, s.serviceName, name, secretName, label, secret) - encoded, err := s.encodePayload(payload) - if err != nil { - return err - } - - if _, err := s.execute( - fmt.Sprintf("update bitwarden item for secret %q", name), - nil, - "edit", - "item", - item.ID, - encoded, - ); err != nil { - return err - } - - if s.cache != nil { - s.cache.invalidate(name, secretName) - s.cache.store(name, secretName, secret) - } - return nil -} - -func (s *bitwardenStore) GetSecret(name string) (string, error) { - secretName := s.scopedName(name) - if s.cache != nil { - if secret, ok := s.cache.load(name, secretName); ok { - return secret, nil - } - } - - if err := s.ensureReady(); err != nil { - return "", fmt.Errorf("prepare bitwarden CLI for reading secret %q: %w", name, err) - } - - _, payload, err := s.findItem(secretName, name) - if err != nil { - return "", err - } - - secret, ok := readBitwardenSecret(payload) - if !ok { - return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName) - } - - if s.cache != nil { - s.cache.store(name, secretName, secret) - } - return secret, nil -} - -func (s *bitwardenStore) DeleteSecret(name string) error { - secretName := s.scopedName(name) - if err := s.ensureReady(); err != nil { - return fmt.Errorf("prepare bitwarden CLI for deleting secret %q: %w", name, err) - } - - item, _, err := s.findItem(secretName, name) - if errors.Is(err, ErrNotFound) { - if s.cache != nil { - s.cache.invalidate(name, secretName) - } - return nil - } - if err != nil { - return err - } - - if _, err := s.execute( - fmt.Sprintf("delete bitwarden item for secret %q", name), - nil, - "delete", - "item", - item.ID, - ); err != nil { - return err - } - - if s.cache != nil { - s.cache.invalidate(name, secretName) - } - return nil -} - -func (s *bitwardenStore) scopedName(name string) string { - return fmt.Sprintf("%s/%s", s.serviceName, name) -} - -func (s *bitwardenStore) ensureReady() error { - return verifyBitwardenCLIReady(Options{ - BitwardenCommand: s.command, - BitwardenDebug: s.debug, - LookupEnv: s.lookupEnv, - Shell: s.shell, - }) -} - -type bitwardenResolvedItem struct { - item bitwardenListItem - payload map[string]any -} - -func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenListItem, map[string]any, error) { - output, err := s.execute( - fmt.Sprintf("list bitwarden items for secret %q", secretName), - nil, - "list", - "items", - "--search", - secretName, - ) - if err != nil { - return bitwardenListItem{}, nil, err - } - if strings.TrimSpace(string(output)) == "" { - return bitwardenListItem{}, nil, ErrNotFound - } - - var items []bitwardenListItem - if err := json.Unmarshal(output, &items); err != nil { - return bitwardenListItem{}, nil, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err) - } - - matches := make([]bitwardenListItem, 0, len(items)) - for _, item := range items { - if strings.TrimSpace(item.Name) != secretName { - continue - } - if strings.TrimSpace(item.ID) == "" { - continue - } - matches = append(matches, item) - } - - if len(matches) == 0 { - return bitwardenListItem{}, nil, ErrNotFound - } - - markedMatches := make([]bitwardenResolvedItem, 0, len(matches)) - legacyMatches := make([]bitwardenResolvedItem, 0, len(matches)) - for _, item := range matches { - payload, err := s.itemByID(item.ID) - if err != nil { - return bitwardenListItem{}, nil, err - } - - resolved := bitwardenResolvedItem{ - item: item, - payload: payload, - } - - if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) { - markedMatches = append(markedMatches, resolved) - continue - } - legacyMatches = append(legacyMatches, resolved) - } - - switch len(markedMatches) { - case 0: - switch len(legacyMatches) { - case 0: - return bitwardenListItem{}, nil, ErrNotFound - case 1: - return legacyMatches[0].item, legacyMatches[0].payload, nil - default: - return bitwardenListItem{}, nil, fmt.Errorf( - "multiple legacy bitwarden items match secret %q for service %q", - secretName, - s.serviceName, - ) - } - case 1: - return markedMatches[0].item, markedMatches[0].payload, nil - default: - return bitwardenListItem{}, nil, fmt.Errorf( - "multiple bitwarden items share marker for secret %q and service %q", - secretName, - s.serviceName, - ) - } -} - -func (s *bitwardenStore) itemTemplate() (map[string]any, error) { - output, err := s.execute("load bitwarden item template", nil, "get", "template", "item") - if err != nil { - return nil, err - } - - var payload map[string]any - if err := json.Unmarshal(output, &payload); err != nil { - return nil, fmt.Errorf("decode bitwarden item template: %w", err) - } - - return payload, nil -} - -func (s *bitwardenStore) itemByID(id string) (map[string]any, error) { - trimmedID := strings.TrimSpace(id) - if trimmedID == "" { - return nil, errors.New("bitwarden item id must not be empty") - } - - output, err := s.execute( - fmt.Sprintf("read bitwarden item %q", trimmedID), - nil, - "get", - "item", - trimmedID, - ) - if err != nil { - return nil, err - } - - var payload map[string]any - if err := json.Unmarshal(output, &payload); err != nil { - return nil, fmt.Errorf("decode bitwarden item %q: %w", trimmedID, err) - } - - return payload, nil -} - -func (s *bitwardenStore) encodePayload(payload map[string]any) (string, error) { - raw, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("encode bitwarden payload: %w", err) - } - - output, err := s.execute("encode bitwarden payload", raw, "encode") - if err != nil { - return "", err - } - - encoded := strings.TrimSpace(string(output)) - if encoded == "" { - return "", errors.New("bitwarden CLI returned an empty encoded payload") - } - - return encoded, nil -} - -func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) { - output, err := runBitwardenCommand(s.command, s.debug, stdin, args...) - if err != nil { - return nil, fmt.Errorf("%s: %w", operation, err) - } - - return output, nil -} - -func setBitwardenSecretPayload(payload map[string]any, serviceName, rawSecretName, secretName, label, secret string) { - payload["type"] = 2 - payload["name"] = secretName - payload["notes"] = strings.TrimSpace(label) - payload["secureNote"] = map[string]any{"type": 0} - payload["fields"] = []map[string]any{ - { - "name": bitwardenSecretFieldName, - "value": secret, - "type": 1, - }, - { - "name": bitwardenServiceFieldName, - "value": strings.TrimSpace(serviceName), - "type": 0, - }, - { - "name": bitwardenSecretNameFieldName, - "value": strings.TrimSpace(rawSecretName), - "type": 0, - }, - } -} - -func readBitwardenSecret(payload map[string]any) (string, bool) { - return readBitwardenField(payload, bitwardenSecretFieldName) -} - -func readBitwardenField(payload map[string]any, fieldName string) (string, bool) { - rawFields, ok := payload["fields"] - if !ok { - return "", false - } - - fields, ok := rawFields.([]any) - if !ok { - return "", false - } - - for _, rawField := range fields { - field, ok := rawField.(map[string]any) - if !ok { - continue - } - - name, _ := field["name"].(string) - if strings.TrimSpace(name) != strings.TrimSpace(fieldName) { - continue - } - - value, ok := field["value"].(string) - if !ok { - return "", false - } - - return value, true - } - - return "", false -} - -func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName string) bool { - markedService, ok := readBitwardenField(payload, bitwardenServiceFieldName) - if !ok { - return false - } - markedSecretName, ok := readBitwardenField(payload, bitwardenSecretNameFieldName) - if !ok { - return false - } - - return strings.TrimSpace(markedService) == strings.TrimSpace(serviceName) && - strings.TrimSpace(markedSecretName) == strings.TrimSpace(secretName) -} - -func runBitwardenCommand(command string, debug bool, stdin []byte, args ...string) ([]byte, error) { - if debug { - logBitwardenCommand(command, args...) - } - return runBitwardenCLI(command, stdin, args...) -} - -func runBitwardenInteractiveCommand( - command string, - debug bool, - stdin io.Reader, - stdout, stderr io.Writer, - args ...string, -) ([]byte, error) { - if debug { - logBitwardenCommand(command, args...) - } - return runBitwardenInteractiveCLI(command, stdin, stdout, stderr, args...) -} - -func isBitwardenDebugEnabled(explicit bool) bool { - if explicit { - return true - } - - raw, ok := os.LookupEnv(bitwardenDebugEnvName) - if !ok { - return false - } - - switch strings.ToLower(strings.TrimSpace(raw)) { - case "1", "true", "yes", "y", "on": - return true - default: - return false - } -} - -func logBitwardenCommand(command string, args ...string) { - writer := bitwardenDebugOutput - if writer == nil { - return - } - - renderedArgs := sanitizeBitwardenDebugArgs(args) - if len(renderedArgs) == 0 { - _, _ = fmt.Fprintf(writer, "[bitwarden debug] %s\n", strings.TrimSpace(command)) - return - } - - _, _ = fmt.Fprintf( - writer, - "[bitwarden debug] %s %s\n", - strings.TrimSpace(command), - strings.Join(renderedArgs, " "), - ) -} - -func sanitizeBitwardenDebugArgs(args []string) []string { - if len(args) == 0 { - return nil - } - - rendered := make([]string, len(args)) - for idx, arg := range args { - rendered[idx] = strings.TrimSpace(arg) - } - - if len(rendered) >= 3 && rendered[0] == "create" && rendered[1] == "item" { - rendered[2] = "" - } - if len(rendered) >= 4 && rendered[0] == "edit" && rendered[1] == "item" { - rendered[3] = "" - } - - return rendered -} - -func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { - stopLoader := startBitwardenLoaderFunc() - defer stopLoader() - - cmd := exec.Command(command, args...) - if stdin != nil { - cmd.Stdin = bytes.NewReader(stdin) - } - - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return nil, normalizeBitwardenExecutionError(err, stderr.String(), stdout.String()) - } - - return stdout.Bytes(), nil -} - -func executeBitwardenCLIInteractive( - command string, - stdin io.Reader, - stdout, stderr io.Writer, - args ...string, -) ([]byte, error) { - cmd := exec.Command(command, args...) - if stdin != nil { - cmd.Stdin = stdin - } - - var stdoutBuffer bytes.Buffer - if stdout == nil { - cmd.Stdout = &stdoutBuffer - } else { - cmd.Stdout = io.MultiWriter(stdout, &stdoutBuffer) - } - - var stderrBuffer bytes.Buffer - if stderr == nil { - cmd.Stderr = &stderrBuffer - } else { - cmd.Stderr = io.MultiWriter(stderr, &stderrBuffer) - } - - if err := cmd.Run(); err != nil { - return nil, normalizeBitwardenExecutionError(err, stderrBuffer.String(), stdoutBuffer.String()) - } - - return stdoutBuffer.Bytes(), nil -} - -func startBitwardenLoader() func() { - if !shouldShowBitwardenLoader() { - return func() {} - } - if !bitwardenLoaderActive.CompareAndSwap(false, true) { - return func() {} - } - - done := make(chan struct{}) - stopped := make(chan struct{}) - - go func() { - defer close(stopped) - - ticker := time.NewTicker(bitwardenLoaderInterval) - defer ticker.Stop() - - phase := 0 - for { - _, _ = fmt.Fprint(os.Stdout, bitwardenLoaderFrame(phase)) - phase++ - - select { - case <-done: - _, _ = fmt.Fprint(os.Stdout, bitwardenLoaderClearLine) - return - case <-ticker.C: - } - } - }() - - var stopOnce sync.Once - return func() { - stopOnce.Do(func() { - close(done) - <-stopped - bitwardenLoaderActive.Store(false) - }) - } -} - -func shouldShowBitwardenLoader() bool { - term := strings.TrimSpace(os.Getenv("TERM")) - if term == "" || strings.EqualFold(term, "dumb") { - return false - } - - info, err := os.Stdout.Stat() - if err != nil { - return false - } - - return (info.Mode() & os.ModeCharDevice) != 0 -} - -func bitwardenLoaderFrame(phase int) string { - chars := []rune(bitwardenLoaderMessage) - if len(chars) == 0 { - return bitwardenLoaderClearLine - } - - waveIndex := phase % len(chars) - if waveIndex < 0 { - waveIndex += len(chars) - } - - var frame strings.Builder - frame.Grow(len(chars)*14 + len(bitwardenLoaderClearLine) + len(bitwardenLoaderResetColor)) - frame.WriteString(bitwardenLoaderClearLine) - - for idx, char := range chars { - frame.WriteString(bitwardenLoaderColorForIndex(idx, waveIndex, len(chars))) - frame.WriteRune(char) - } - - frame.WriteString(bitwardenLoaderResetColor) - return frame.String() -} - -func bitwardenLoaderColorForIndex(idx, waveIndex, length int) string { - if length <= 1 { - return bitwardenLoaderColorFocus - } - - distance := idx - waveIndex - if distance < 0 { - distance = -distance - } - if wrapped := length - distance; wrapped < distance { - distance = wrapped - } - - switch distance { - case 0: - return bitwardenLoaderColorFocus - case 1: - return bitwardenLoaderColorWave - default: - return bitwardenLoaderColorBase - } -} - -func normalizeBitwardenExecutionError(err error, stderrText, stdoutText string) error { - detail := sanitizeBitwardenErrorDetail(stderrText, stdoutText) - classification := classifyBitwardenError(detail) - if classification == nil { - classification = ErrBWUnavailable - } - - wrapped := errors.Join(classification, err) - if strings.TrimSpace(detail) == "" { - return wrapped - } - - return fmt.Errorf("%w: %s", wrapped, detail) -} - -func sanitizeBitwardenErrorDetail(stderrText, stdoutText string) string { - raw := strings.TrimSpace(stderrText) - if raw == "" { - raw = strings.TrimSpace(stdoutText) - } - if raw == "" { - return "" - } - - lines := strings.Split(raw, "\n") - cleaned := make([]string, 0, len(lines)) - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue - } - - lower := strings.ToLower(trimmed) - if strings.HasPrefix(trimmed, "at ") || - strings.HasPrefix(lower, "node:internal") || - strings.HasPrefix(lower, "internal/") || - strings.HasPrefix(lower, "npm ") { - continue - } - - cleaned = append(cleaned, trimmed) - } - - if len(cleaned) == 0 { - return "" - } - - if len(cleaned) == 1 { - return cleaned[0] - } - - return cleaned[0] + " | " + cleaned[1] -} - -func classifyBitwardenError(detail string) error { - lower := strings.ToLower(strings.TrimSpace(detail)) - - switch { - case strings.Contains(lower, "not logged in"), strings.Contains(lower, "unauthenticated"): - return ErrBWNotLoggedIn - case strings.Contains(lower, "vault is locked"), strings.Contains(lower, "is locked"): - return ErrBWLocked - case strings.Contains(lower, "failed to fetch"), - strings.Contains(lower, "econnrefused"), - strings.Contains(lower, "etimedout"), - strings.Contains(lower, "unable to connect"), - strings.Contains(lower, "network"): - return ErrBWUnavailable - default: - return nil - } -} diff --git a/secretstore/bitwarden_cache.go b/secretstore/bitwarden_cache.go deleted file mode 100644 index 7990e8b..0000000 --- a/secretstore/bitwarden_cache.go +++ /dev/null @@ -1,378 +0,0 @@ -package secretstore - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/hkdf" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" -) - -const ( - bitwardenCacheEnvName = "MCP_FRAMEWORK_BITWARDEN_CACHE" - defaultBitwardenCacheTTL = 10 * time.Minute - bitwardenCacheFormatVersion = 1 - bitwardenCacheAlgorithm = "AES-256-GCM" - bitwardenCacheDirName = "bitwarden-cache" - bitwardenCacheSalt = "mcp-framework bitwarden cache salt v1" - bitwardenCacheInfo = "mcp-framework bitwarden cache v1" - bitwardenCacheEncryptionInfo = "mcp-framework bitwarden cache encryption v1" - bitwardenCacheEntryIDInfo = "mcp-framework bitwarden cache entry id v1" - bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1" -) - -var bitwardenUserCacheDir = os.UserCacheDir - -type bitwardenCacheOptions struct { - ServiceName string - Session string - TTL time.Duration - Now func() time.Time - CacheDir string - Enabled bool -} - -type bitwardenCache struct { - mu sync.Mutex - enabled bool - serviceName string - ttl time.Duration - now func() time.Time - cacheDir string - encryptionKey []byte - entryIDKey []byte - memory map[string]bitwardenCacheMemoryEntry -} - -type bitwardenCacheMemoryEntry struct { - value string - expiresAt time.Time -} - -type bitwardenCachePlaintext struct { - Version int `json:"version"` - ServiceName string `json:"service_name"` - SecretName string `json:"secret_name"` - ScopedName string `json:"scoped_name"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` - Value string `json:"value"` -} - -type bitwardenCacheEnvelope struct { - Version int `json:"version"` - Algorithm string `json:"algorithm"` - Nonce string `json:"nonce"` - Ciphertext string `json:"ciphertext"` -} - -func newBitwardenCache(options bitwardenCacheOptions) *bitwardenCache { - now := options.Now - if now == nil { - now = time.Now - } - ttl := options.TTL - if ttl <= 0 { - ttl = defaultBitwardenCacheTTL - } - - cache := &bitwardenCache{ - enabled: options.Enabled, - serviceName: strings.TrimSpace(options.ServiceName), - ttl: ttl, - now: now, - cacheDir: strings.TrimSpace(options.CacheDir), - memory: map[string]bitwardenCacheMemoryEntry{}, - } - if !cache.enabled { - return cache - } - - session := strings.TrimSpace(options.Session) - if session == "" { - return cache - } - masterKey, err := hkdf.Key(sha256.New, []byte(session), []byte(bitwardenCacheSalt), bitwardenCacheInfo, 32) - if err != nil { - return cache - } - cache.encryptionKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEncryptionInfo, 32) - if err != nil { - cache.encryptionKey = nil - return cache - } - cache.entryIDKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEntryIDInfo, 32) - if err != nil { - cache.encryptionKey = nil - cache.entryIDKey = nil - return cache - } - return cache -} - -func (c *bitwardenCache) load(secretName, scopedName string) (string, bool) { - if value, ok := c.loadMemory(secretName, scopedName); ok { - return value, true - } - return c.loadDisk(secretName, scopedName) -} - -func (c *bitwardenCache) store(secretName, scopedName, value string) { - if c == nil || !c.enabled { - return - } - c.storeMemory(secretName, scopedName, value) - c.storeDisk(secretName, scopedName, value) -} - -func (c *bitwardenCache) invalidate(secretName, scopedName string) { - if c == nil { - return - } - key := c.memoryKey(secretName, scopedName) - c.mu.Lock() - delete(c.memory, key) - c.mu.Unlock() - if path, ok := c.entryPath(secretName, scopedName); ok { - _ = os.Remove(path) - } -} - -func (c *bitwardenCache) loadMemory(secretName, scopedName string) (string, bool) { - if c == nil || !c.enabled { - return "", false - } - key := c.memoryKey(secretName, scopedName) - now := c.now() - c.mu.Lock() - defer c.mu.Unlock() - entry, ok := c.memory[key] - if !ok { - return "", false - } - if !entry.expiresAt.After(now) { - delete(c.memory, key) - return "", false - } - return entry.value, true -} - -func (c *bitwardenCache) storeMemory(secretName, scopedName, value string) { - key := c.memoryKey(secretName, scopedName) - c.mu.Lock() - c.memory[key] = bitwardenCacheMemoryEntry{ - value: value, - expiresAt: c.now().Add(c.ttl), - } - c.mu.Unlock() -} - -func (c *bitwardenCache) loadDisk(secretName, scopedName string) (string, bool) { - path, ok := c.entryPath(secretName, scopedName) - if !ok { - return "", false - } - data, err := os.ReadFile(path) - if err != nil { - return "", false - } - var envelope bitwardenCacheEnvelope - if err := json.Unmarshal(data, &envelope); err != nil { - _ = os.Remove(path) - return "", false - } - plaintext, err := c.decryptEnvelope(secretName, scopedName, envelope) - if err != nil { - _ = os.Remove(path) - return "", false - } - if plaintext.Version != bitwardenCacheFormatVersion || - plaintext.ServiceName != c.serviceName || - plaintext.SecretName != strings.TrimSpace(secretName) || - plaintext.ScopedName != strings.TrimSpace(scopedName) || - !plaintext.ExpiresAt.After(c.now()) { - _ = os.Remove(path) - return "", false - } - c.storeMemory(secretName, scopedName, plaintext.Value) - return plaintext.Value, true -} - -func (c *bitwardenCache) storeDisk(secretName, scopedName, value string) { - if c.cacheDir == "" || len(c.encryptionKey) == 0 || len(c.entryIDKey) == 0 { - return - } - path, ok := c.entryPath(secretName, scopedName) - if !ok { - return - } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return - } - _ = os.Chmod(filepath.Dir(path), 0o700) - - now := c.now() - plaintext := bitwardenCachePlaintext{ - Version: bitwardenCacheFormatVersion, - ServiceName: c.serviceName, - SecretName: strings.TrimSpace(secretName), - ScopedName: strings.TrimSpace(scopedName), - CreatedAt: now, - ExpiresAt: now.Add(c.ttl), - Value: value, - } - envelope, err := c.encryptPlaintext(secretName, scopedName, plaintext) - if err != nil { - return - } - data, err := json.Marshal(envelope) - if err != nil { - return - } - tmp, err := os.CreateTemp(filepath.Dir(path), "bitwarden-cache-*.tmp") - if err != nil { - return - } - tmpPath := tmp.Name() - cleanup := true - defer func() { - _ = tmp.Close() - if cleanup { - _ = os.Remove(tmpPath) - } - }() - _ = tmp.Chmod(0o600) - if _, err := tmp.Write(data); err != nil { - return - } - if err := tmp.Close(); err != nil { - return - } - if err := os.Rename(tmpPath, path); err != nil { - return - } - _ = os.Chmod(path, 0o600) - cleanup = false -} - -func (c *bitwardenCache) encryptPlaintext(secretName, scopedName string, plaintext bitwardenCachePlaintext) (bitwardenCacheEnvelope, error) { - raw, err := json.Marshal(plaintext) - if err != nil { - return bitwardenCacheEnvelope{}, err - } - block, err := aes.NewCipher(c.encryptionKey) - if err != nil { - return bitwardenCacheEnvelope{}, err - } - aead, err := cipher.NewGCM(block) - if err != nil { - return bitwardenCacheEnvelope{}, err - } - nonce := make([]byte, aead.NonceSize()) - if _, err := rand.Read(nonce); err != nil { - return bitwardenCacheEnvelope{}, err - } - ciphertext := aead.Seal(nil, nonce, raw, c.additionalData(secretName, scopedName)) - return bitwardenCacheEnvelope{ - Version: bitwardenCacheFormatVersion, - Algorithm: bitwardenCacheAlgorithm, - Nonce: base64.StdEncoding.EncodeToString(nonce), - Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), - }, nil -} - -func (c *bitwardenCache) decryptEnvelope(secretName, scopedName string, envelope bitwardenCacheEnvelope) (bitwardenCachePlaintext, error) { - if envelope.Version != bitwardenCacheFormatVersion || envelope.Algorithm != bitwardenCacheAlgorithm { - return bitwardenCachePlaintext{}, errors.New("unsupported bitwarden cache envelope") - } - nonce, err := base64.StdEncoding.DecodeString(envelope.Nonce) - if err != nil { - return bitwardenCachePlaintext{}, err - } - ciphertext, err := base64.StdEncoding.DecodeString(envelope.Ciphertext) - if err != nil { - return bitwardenCachePlaintext{}, err - } - block, err := aes.NewCipher(c.encryptionKey) - if err != nil { - return bitwardenCachePlaintext{}, err - } - aead, err := cipher.NewGCM(block) - if err != nil { - return bitwardenCachePlaintext{}, err - } - raw, err := aead.Open(nil, nonce, ciphertext, c.additionalData(secretName, scopedName)) - if err != nil { - return bitwardenCachePlaintext{}, err - } - var plaintext bitwardenCachePlaintext - if err := json.Unmarshal(raw, &plaintext); err != nil { - return bitwardenCachePlaintext{}, err - } - return plaintext, nil -} - -func (c *bitwardenCache) entryPath(secretName, scopedName string) (string, bool) { - if c == nil || !c.enabled || c.cacheDir == "" || len(c.entryIDKey) == 0 { - return "", false - } - mac := hmac.New(sha256.New, c.entryIDKey) - _, _ = mac.Write([]byte(c.cacheContext(secretName, scopedName))) - return filepath.Join(c.cacheDir, hex.EncodeToString(mac.Sum(nil))+".json"), true -} - -func (c *bitwardenCache) memoryKey(secretName, scopedName string) string { - return c.cacheContext(secretName, scopedName) -} - -func (c *bitwardenCache) additionalData(secretName, scopedName string) []byte { - return []byte(fmt.Sprintf( - "mcp-framework bitwarden cache v1\nservice=%s\nsecret=%s\nscoped=%s", - c.serviceName, - strings.TrimSpace(secretName), - strings.TrimSpace(scopedName), - )) -} - -func (c *bitwardenCache) cacheContext(secretName, scopedName string) string { - return fmt.Sprintf( - "version=%d\nservice=%s\nsecret=%s\nscoped=%s\nscope=%s", - bitwardenCacheFormatVersion, - c.serviceName, - strings.TrimSpace(secretName), - strings.TrimSpace(scopedName), - bitwardenCacheContextScope, - ) -} - -func resolveBitwardenCacheDir(serviceName string) string { - cacheRoot, err := bitwardenUserCacheDir() - if err != nil { - return "" - } - return filepath.Join(cacheRoot, strings.TrimSpace(serviceName), bitwardenCacheDirName) -} - -func bitwardenCacheDisabledByEnv() bool { - raw, ok := os.LookupEnv(bitwardenCacheEnvName) - if !ok { - return false - } - switch strings.ToLower(strings.TrimSpace(raw)) { - case "0", "false", "no", "off", "disabled": - return true - default: - return false - } -} diff --git a/secretstore/bitwarden_cache_test.go b/secretstore/bitwarden_cache_test.go deleted file mode 100644 index d26097d..0000000 --- a/secretstore/bitwarden_cache_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package secretstore - -import ( - "bytes" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestBitwardenCacheMemoryHit(t *testing.T) { - cache := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v1", - TTL: 10 * time.Minute, - Now: func() time.Time { return time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) }, - CacheDir: t.TempDir(), - Enabled: true, - }) - - cache.store("api-token", "email-mcp/api-token", "secret-v1") - got, ok := cache.loadMemory("api-token", "email-mcp/api-token") - if !ok { - t.Fatal("memory cache miss, want hit") - } - if got != "secret-v1" { - t.Fatalf("memory cache value = %q, want secret-v1", got) - } -} - -func TestBitwardenCacheDiskRoundTripIsEncrypted(t *testing.T) { - dir := t.TempDir() - now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) - cache := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v1", - TTL: 10 * time.Minute, - Now: func() time.Time { return now }, - CacheDir: dir, - Enabled: true, - }) - - cache.store("api-token", "email-mcp/api-token", "secret-v1") - - reopened := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v1", - TTL: 10 * time.Minute, - Now: func() time.Time { return now.Add(time.Minute) }, - CacheDir: dir, - Enabled: true, - }) - got, ok := reopened.loadDisk("api-token", "email-mcp/api-token") - if !ok { - t.Fatal("disk cache miss, want hit") - } - if got != "secret-v1" { - t.Fatalf("disk cache value = %q, want secret-v1", got) - } - - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatalf("ReadDir cache dir: %v", err) - } - if len(entries) != 1 { - t.Fatalf("cache file count = %d, want 1", len(entries)) - } - data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) - if err != nil { - t.Fatalf("ReadFile cache file: %v", err) - } - if bytes.Contains(data, []byte("secret-v1")) { - t.Fatalf("cache file contains plaintext secret: %s", data) - } - if strings.Contains(entries[0].Name(), "api-token") { - t.Fatalf("cache file name exposes secret name: %s", entries[0].Name()) - } -} - -func TestBitwardenCacheRejectsChangedSession(t *testing.T) { - dir := t.TempDir() - now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) - cache := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v1", - TTL: 10 * time.Minute, - Now: func() time.Time { return now }, - CacheDir: dir, - Enabled: true, - }) - cache.store("api-token", "email-mcp/api-token", "secret-v1") - - changed := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v2", - TTL: 10 * time.Minute, - Now: func() time.Time { return now.Add(time.Minute) }, - CacheDir: dir, - Enabled: true, - }) - if got, ok := changed.loadDisk("api-token", "email-mcp/api-token"); ok { - t.Fatalf("disk cache hit with changed session = %q, want miss", got) - } -} - -func TestBitwardenCacheExpiresEntries(t *testing.T) { - dir := t.TempDir() - now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) - cache := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v1", - TTL: time.Minute, - Now: func() time.Time { return now }, - CacheDir: dir, - Enabled: true, - }) - cache.store("api-token", "email-mcp/api-token", "secret-v1") - - expired := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: "session-v1", - TTL: time.Minute, - Now: func() time.Time { return now.Add(2 * time.Minute) }, - CacheDir: dir, - Enabled: true, - }) - if got, ok := expired.load("api-token", "email-mcp/api-token"); ok { - t.Fatalf("expired cache hit = %q, want miss", got) - } -} - -func withBitwardenUserCacheDir(t *testing.T, resolver func() (string, error)) { - t.Helper() - - previous := bitwardenUserCacheDir - bitwardenUserCacheDir = resolver - t.Cleanup(func() { - bitwardenUserCacheDir = previous - }) -} diff --git a/secretstore/bitwarden_session.go b/secretstore/bitwarden_session.go deleted file mode 100644 index ff4d814..0000000 --- a/secretstore/bitwarden_session.go +++ /dev/null @@ -1,197 +0,0 @@ -package secretstore - -import ( - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" -) - -const bitwardenSessionFileName = "bw-session" - -var bitwardenUserConfigDir = os.UserConfigDir - -type BitwardenSessionOptions struct { - ServiceName string -} - -type BitwardenLoginOptions struct { - ServiceName string - BitwardenCommand string - BitwardenDebug bool - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer -} - -func SaveBitwardenSession(options BitwardenSessionOptions, session string) (string, error) { - trimmedSession := strings.TrimSpace(session) - if trimmedSession == "" { - return "", errors.New("bitwarden session must not be empty") - } - - path, err := resolveBitwardenSessionPath(options) - if err != nil { - return "", err - } - - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o700); err != nil { - return "", fmt.Errorf("create bitwarden session dir %q: %w", dir, err) - } - if err := os.Chmod(dir, 0o700); err != nil { - return "", fmt.Errorf("set bitwarden session dir permissions %q: %w", dir, err) - } - - tmpFile, err := os.CreateTemp(dir, "bw-session-*.tmp") - if err != nil { - return "", fmt.Errorf("create temp bitwarden session in %q: %w", dir, err) - } - - tmpPath := tmpFile.Name() - cleanup := true - defer func() { - _ = tmpFile.Close() - if cleanup { - _ = os.Remove(tmpPath) - } - }() - - if err := tmpFile.Chmod(0o600); err != nil { - return "", fmt.Errorf("set bitwarden session temp file permissions %q: %w", tmpPath, err) - } - if _, err := tmpFile.WriteString(trimmedSession + "\n"); err != nil { - return "", fmt.Errorf("write bitwarden session temp file %q: %w", tmpPath, err) - } - if err := tmpFile.Close(); err != nil { - return "", fmt.Errorf("close bitwarden session temp file %q: %w", tmpPath, err) - } - if err := os.Rename(tmpPath, path); err != nil { - return "", fmt.Errorf("replace bitwarden session file %q: %w", path, err) - } - if err := os.Chmod(path, 0o600); err != nil { - return "", fmt.Errorf("set bitwarden session file permissions %q: %w", path, err) - } - - cleanup = false - return path, nil -} - -func LoadBitwardenSession(options BitwardenSessionOptions) (string, error) { - path, err := resolveBitwardenSessionPath(options) - if err != nil { - return "", err - } - - data, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return "", ErrNotFound - } - return "", fmt.Errorf("read bitwarden session file %q: %w", path, err) - } - - session := strings.TrimSpace(string(data)) - if session == "" { - return "", ErrNotFound - } - - return session, nil -} - -func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) { - if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" { - return false, nil - } - - session, err := LoadBitwardenSession(options) - if err != nil { - if errors.Is(err, ErrNotFound) { - return false, nil - } - return false, err - } - - if err := os.Setenv(bitwardenSessionEnvName, session); err != nil { - return false, fmt.Errorf("set %s from persisted bitwarden session: %w", bitwardenSessionEnvName, err) - } - - return true, nil -} - -func LoginBitwarden(options BitwardenLoginOptions) (string, error) { - serviceName := strings.TrimSpace(options.ServiceName) - if serviceName == "" { - return "", errors.New("service name must not be empty") - } - - command := strings.TrimSpace(options.BitwardenCommand) - if command == "" { - command = defaultBitwardenCommand - } - debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) - - stdin := options.Stdin - if stdin == nil { - stdin = os.Stdin - } - stdout := options.Stdout - if stdout == nil { - stdout = os.Stdout - } - stderr := options.Stderr - if stderr == nil { - stderr = os.Stderr - } - - status, err := readBitwardenStatus(command, debugEnabled) - if err != nil { - return "", err - } - - switch status { - case "unauthenticated": - if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil { - return "", fmt.Errorf("login to bitwarden CLI: %w", err) - } - case "locked", "unlocked": - default: - return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status) - } - - unlockOutput, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, nil, stderr, "unlock", "--raw") - if err != nil { - return "", fmt.Errorf("unlock bitwarden vault: %w", err) - } - - session := strings.TrimSpace(string(unlockOutput)) - if session == "" { - return "", errors.New("bitwarden CLI returned an empty session after unlock") - } - - if err := os.Setenv(bitwardenSessionEnvName, session); err != nil { - return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err) - } - - if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil { - return "", fmt.Errorf("persist bitwarden session: %w", err) - } - - return session, nil -} - -func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) { - serviceName := strings.TrimSpace(options.ServiceName) - if serviceName == "" { - return "", errors.New("service name must not be empty") - } - - userConfigDir, err := bitwardenUserConfigDir() - if err != nil { - return "", fmt.Errorf("resolve user config dir for bitwarden session: %w", err) - } - - return filepath.Join(userConfigDir, serviceName, bitwardenSessionFileName), nil -} diff --git a/secretstore/bitwarden_session_test.go b/secretstore/bitwarden_session_test.go deleted file mode 100644 index 3b8e3e4..0000000 --- a/secretstore/bitwarden_session_test.go +++ /dev/null @@ -1,231 +0,0 @@ -package secretstore - -import ( - "errors" - "fmt" - "io" - "os" - "slices" - "testing" -) - -func TestLoginBitwardenRunsInteractiveFlowAndPersistsSession(t *testing.T) { - t.Setenv("BW_SESSION", "") - configDir := t.TempDir() - withBitwardenUserConfigDir(t, func() (string, error) { - return configDir, nil - }) - - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - if len(args) == 1 && args[0] == "status" { - return []byte(`{"status":"unauthenticated"}`), nil - } - return nil, fmt.Errorf("unexpected args: %v", args) - }) - - var calls [][]string - withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { - calls = append(calls, slices.Clone(args)) - switch { - case len(args) == 1 && args[0] == "login": - return nil, nil - case len(args) == 2 && args[0] == "unlock" && args[1] == "--raw": - return []byte("persisted-session\n"), nil - default: - return nil, fmt.Errorf("unexpected interactive args: %v", args) - } - }) - - session, err := LoginBitwarden(BitwardenLoginOptions{ - ServiceName: "email-mcp", - BitwardenCommand: "bw", - }) - if err != nil { - t.Fatalf("LoginBitwarden returned error: %v", err) - } - if session != "persisted-session" { - t.Fatalf("session = %q, want persisted-session", session) - } - if got := os.Getenv("BW_SESSION"); got != "persisted-session" { - t.Fatalf("BW_SESSION = %q, want persisted-session", got) - } - - if len(calls) != 2 { - t.Fatalf("interactive call count = %d, want 2", len(calls)) - } - if !slices.Equal(calls[0], []string{"login"}) { - t.Fatalf("interactive call #1 args = %v, want [login]", calls[0]) - } - if !slices.Equal(calls[1], []string{"unlock", "--raw"}) { - t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1]) - } - - persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}) - if err != nil { - t.Fatalf("LoadBitwardenSession returned error: %v", err) - } - if persisted != "persisted-session" { - t.Fatalf("persisted session = %q, want persisted-session", persisted) - } -} - -func TestLoginBitwardenSkipsInteractiveLoginWhenAlreadyAuthenticated(t *testing.T) { - t.Setenv("BW_SESSION", "") - configDir := t.TempDir() - withBitwardenUserConfigDir(t, func() (string, error) { - return configDir, nil - }) - - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - if len(args) == 1 && args[0] == "status" { - return []byte(`{"status":"locked"}`), nil - } - return nil, fmt.Errorf("unexpected args: %v", args) - }) - - var calls [][]string - withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { - calls = append(calls, slices.Clone(args)) - if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" { - return []byte("session-locked\n"), nil - } - return nil, fmt.Errorf("unexpected interactive args: %v", args) - }) - - session, err := LoginBitwarden(BitwardenLoginOptions{ - ServiceName: "email-mcp", - BitwardenCommand: "bw", - }) - if err != nil { - t.Fatalf("LoginBitwarden returned error: %v", err) - } - if session != "session-locked" { - t.Fatalf("session = %q, want session-locked", session) - } - - if len(calls) != 1 { - t.Fatalf("interactive call count = %d, want 1", len(calls)) - } - if !slices.Equal(calls[0], []string{"unlock", "--raw"}) { - t.Fatalf("interactive call args = %v, want [unlock --raw]", calls[0]) - } -} - -func TestBitwardenSessionEnvLoadsFromPersistedSession(t *testing.T) { - t.Setenv("BW_SESSION", "") - configDir := t.TempDir() - withBitwardenUserConfigDir(t, func() (string, error) { - return configDir, nil - }) - - path, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session") - if err != nil { - t.Fatalf("SaveBitwardenSession returned error: %v", err) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("Stat persisted session file: %v", err) - } - if got := info.Mode().Perm(); got != 0o600 { - t.Fatalf("session file mode = %o, want 600", got) - } - - loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"}) - if err != nil { - t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err) - } - if !loaded { - t.Fatal("expected session to be loaded from persisted file") - } - if got := os.Getenv("BW_SESSION"); got != "persisted-session" { - t.Fatalf("BW_SESSION = %q, want persisted-session", got) - } -} - -func TestBitwardenSessionEnvDoesNotOverrideExistingValue(t *testing.T) { - t.Setenv("BW_SESSION", "from-env") - configDir := t.TempDir() - withBitwardenUserConfigDir(t, func() (string, error) { - return configDir, nil - }) - - if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session"); err != nil { - t.Fatalf("SaveBitwardenSession returned error: %v", err) - } - - loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"}) - if err != nil { - t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err) - } - if loaded { - t.Fatal("expected existing BW_SESSION to be kept") - } - if got := os.Getenv("BW_SESSION"); got != "from-env" { - t.Fatalf("BW_SESSION = %q, want from-env", got) - } -} - -func TestOpenBitwardenCLILoadsPersistedSessionWhenEnvironmentIsMissing(t *testing.T) { - t.Setenv("BW_SESSION", "") - configDir := t.TempDir() - withBitwardenUserConfigDir(t, func() (string, error) { - return configDir, nil - }) - - if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-open-session"); err != nil { - t.Fatalf("SaveBitwardenSession returned error: %v", err) - } - - fakeCLI := newFakeBitwardenCLI("bw") - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - if _, ok := store.(*bitwardenStore); !ok { - t.Fatalf("store type = %T, want *bitwardenStore", store) - } - if got := os.Getenv("BW_SESSION"); got != "persisted-open-session" { - t.Fatalf("BW_SESSION = %q, want persisted-open-session", got) - } -} - -func TestLoadBitwardenSessionReturnsNotFoundWhenFileMissing(t *testing.T) { - configDir := t.TempDir() - withBitwardenUserConfigDir(t, func() (string, error) { - return configDir, nil - }) - - _, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}) - if !errors.Is(err, ErrNotFound) { - t.Fatalf("error = %v, want ErrNotFound", err) - } -} - -func withBitwardenInteractiveRunner( - t *testing.T, - runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error), -) { - t.Helper() - - previous := runBitwardenInteractiveCLI - runBitwardenInteractiveCLI = runner - t.Cleanup(func() { - runBitwardenInteractiveCLI = previous - }) -} - -func withBitwardenUserConfigDir(t *testing.T, resolver func() (string, error)) { - t.Helper() - - previous := bitwardenUserConfigDir - bitwardenUserConfigDir = resolver - t.Cleanup(func() { - bitwardenUserConfigDir = previous - }) -} diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go deleted file mode 100644 index cafdfc3..0000000 --- a/secretstore/bitwarden_test.go +++ /dev/null @@ -1,1029 +0,0 @@ -package secretstore - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - "testing" - "unicode/utf8" - - "gitea.lclr.dev/AI/mcp-framework/manifest" -) - -func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - if _, ok := store.(*bitwardenStore); !ok { - t.Fatalf("store type = %T, want *bitwardenStore", store) - } - if fakeCLI.versionChecked { - t.Fatal("Open should not check bitwarden CLI version before a cache miss or write") - } - if fakeCLI.statusChecked { - t.Fatal("Open should not check bitwarden CLI status before a cache miss or write") - } -} - -func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T) { - withBitwardenSession(t) - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - return nil, &exec.Error{Name: command, Err: exec.ErrNotFound} - }) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - _, err = store.GetSecret("api-token") - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, ErrBackendUnavailable) { - t.Fatalf("error = %v, want ErrBackendUnavailable", err) - } -} - -func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) { - fakeCLI := newFakeBitwardenCLI("bw") - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - LookupEnv: func(name string) (string, bool) { - return "", false - }, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - _, err = store.GetSecret("api-token") - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, ErrBWLocked) { - t.Fatalf("error = %v, want ErrBWLocked", err) - } -} - -func TestEnsureBitwardenReadyGuidesLoginAndUnlock(t *testing.T) { - t.Run("unauthenticated", func(t *testing.T) { - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - if len(args) == 1 && args[0] == "status" { - return []byte(`{"status":"unauthenticated"}`), nil - } - return nil, fmt.Errorf("unexpected args: %v", args) - }) - - err := EnsureBitwardenReady(Options{BitwardenCommand: "bw"}) - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, ErrBWNotLoggedIn) { - t.Fatalf("error = %v, want ErrBWNotLoggedIn", err) - } - if !strings.Contains(err.Error(), "bw login") { - t.Fatalf("error = %v, want guidance with bw login", err) - } - }) - - t.Run("locked", func(t *testing.T) { - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - if len(args) == 1 && args[0] == "status" { - return []byte(`{"status":"locked"}`), nil - } - return nil, fmt.Errorf("unexpected args: %v", args) - }) - - err := EnsureBitwardenReady(Options{BitwardenCommand: "bw"}) - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, ErrBWLocked) { - t.Fatalf("error = %v, want ErrBWLocked", err) - } - if !strings.Contains(err.Error(), "bw unlock --raw") { - t.Fatalf("error = %v, want guidance with bw unlock", err) - } - }) -} - -func TestEnsureBitwardenReadyAcceptsUnlockedSession(t *testing.T) { - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - if len(args) == 1 && args[0] == "status" { - return []byte(`{"status":"unlocked"}`), nil - } - return nil, fmt.Errorf("unexpected args: %v", args) - }) - - err := EnsureBitwardenReady(Options{ - BitwardenCommand: "bw", - LookupEnv: func(name string) (string, bool) { - if name == "BW_SESSION" { - return "session-token", true - } - return "", false - }, - }) - if err != nil { - t.Fatalf("EnsureBitwardenReady returned error: %v", err) - } -} - -func TestEnsureBitwardenReadyAdaptsUnlockRemediationToShell(t *testing.T) { - t.Run("fish", func(t *testing.T) { - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - if len(args) == 1 && args[0] == "status" { - return []byte(`{"status":"locked"}`), nil - } - return nil, fmt.Errorf("unexpected args: %v", args) - }) - - err := EnsureBitwardenReady(Options{BitwardenCommand: "bw", Shell: "fish"}) - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, ErrBWLocked) { - t.Fatalf("error = %v, want ErrBWLocked", err) - } - if !strings.Contains(err.Error(), "set -x BW_SESSION (bw unlock --raw)") { - t.Fatalf("error = %v, want fish unlock remediation", err) - } - }) - - t.Run("powershell", func(t *testing.T) { - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - if len(args) == 1 && args[0] == "status" { - return []byte(`{"status":"unlocked"}`), nil - } - return nil, fmt.Errorf("unexpected args: %v", args) - }) - - err := EnsureBitwardenReady(Options{ - BitwardenCommand: "bw", - Shell: "powershell", - LookupEnv: func(name string) (string, bool) { - return "", false - }, - }) - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, ErrBWLocked) { - t.Fatalf("error = %v, want ErrBWLocked", err) - } - if !strings.Contains(err.Error(), "$env:BW_SESSION = (bw unlock --raw)") { - t.Fatalf("error = %v, want powershell unlock remediation", err) - } - }) -} - -func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "graylog-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { - t.Fatalf("SetSecret returned error: %v", err) - } - - value, err := store.GetSecret("api-token") - if err != nil { - t.Fatalf("GetSecret returned error: %v", err) - } - if value != "secret-v1" { - t.Fatalf("GetSecret = %q, want secret-v1", value) - } - - if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil { - t.Fatalf("SetSecret (update) returned error: %v", err) - } - - value, err = store.GetSecret("api-token") - if err != nil { - t.Fatalf("GetSecret (updated) returned error: %v", err) - } - if value != "secret-v2" { - t.Fatalf("GetSecret (updated) = %q, want secret-v2", value) - } - - if err := store.DeleteSecret("api-token"); err != nil { - t.Fatalf("DeleteSecret returned error: %v", err) - } - - _, err = store.GetSecret("api-token") - if !errors.Is(err, ErrNotFound) { - t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err) - } -} - -func TestBitwardenStoreWritesMarkerFields(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { - t.Fatalf("SetSecret returned error: %v", err) - } - - var found fakeBitwardenItem - for _, item := range fakeCLI.itemsByID { - if item.Name == "email-mcp/api-token" { - found = item - break - } - } - if found.ID == "" { - t.Fatal("expected bitwarden item to be created") - } - if found.MarkerService != "email-mcp" { - t.Fatalf("marker service = %q, want email-mcp", found.MarkerService) - } - if found.MarkerSecretName != "api-token" { - t.Fatalf("marker secret = %q, want api-token", found.MarkerSecretName) - } -} - -func TestBitwardenStorePrefersStrictMarkerMatchWhenNameCollides(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ - ID: "item-1", - Name: "email-mcp/api-token", - Secret: "wrong-secret", - MarkerService: "other-service", - MarkerSecretName: "api-token", - } - fakeCLI.itemsByID["item-2"] = fakeBitwardenItem{ - ID: "item-2", - Name: "email-mcp/api-token", - Secret: "good-secret", - MarkerService: "email-mcp", - MarkerSecretName: "api-token", - } - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - value, err := store.GetSecret("api-token") - if err != nil { - t.Fatalf("GetSecret returned error: %v", err) - } - if value != "good-secret" { - t.Fatalf("GetSecret = %q, want good-secret", value) - } -} - -func TestBitwardenStoreFallsBackToSingleLegacyItemWithoutMarkers(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - fakeCLI.itemsByID["legacy-1"] = fakeBitwardenItem{ - ID: "legacy-1", - Name: "email-mcp/api-token", - Secret: "legacy-secret", - } - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - value, err := store.GetSecret("api-token") - if err != nil { - t.Fatalf("GetSecret returned error: %v", err) - } - if value != "legacy-secret" { - t.Fatalf("GetSecret = %q, want legacy-secret", value) - } -} - -func TestBitwardenStoreGetSecretReadsSelectedItemOnlyOnce(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ - ID: "item-1", - Name: "email-mcp/api-token", - Secret: "secret-v1", - MarkerService: "email-mcp", - MarkerSecretName: "api-token", - } - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - value, err := store.GetSecret("api-token") - if err != nil { - t.Fatalf("GetSecret returned error: %v", err) - } - if value != "secret-v1" { - t.Fatalf("GetSecret = %q, want secret-v1", value) - } - if fakeCLI.getItemCalls != 1 { - t.Fatalf("bw get item count = %d, want 1", fakeCLI.getItemCalls) - } -} - -func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ - ID: "item-1", - Name: "email-mcp/api-token", - Secret: "secret-v1", - MarkerService: "email-mcp", - MarkerSecretName: "api-token", - } - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - for i := 0; i < 2; i++ { - value, err := store.GetSecret("api-token") - if err != nil { - t.Fatalf("GetSecret #%d returned error: %v", i+1, err) - } - if value != "secret-v1" { - t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) - } - } - if fakeCLI.getItemCalls != 1 { - t.Fatalf("bw get item count = %d, want 1 with memory cache", fakeCLI.getItemCalls) - } -} - -func TestBitwardenStoreDiskCacheHitSkipsBitwardenCLI(t *testing.T) { - withBitwardenSession(t) - cacheRoot := t.TempDir() - withBitwardenUserCacheDir(t, func() (string, error) { - return cacheRoot, nil - }) - - cache := newBitwardenCache(bitwardenCacheOptions{ - ServiceName: "email-mcp", - Session: os.Getenv("BW_SESSION"), - TTL: defaultBitwardenCacheTTL, - CacheDir: resolveBitwardenCacheDir("email-mcp"), - Enabled: true, - }) - cache.store("api-token", "email-mcp/api-token", "secret-from-cache") - - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - return nil, fmt.Errorf("unexpected bitwarden invocation: %v", args) - }) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - value, err := store.GetSecret("api-token") - if err != nil { - t.Fatalf("GetSecret returned error: %v", err) - } - if value != "secret-from-cache" { - t.Fatalf("GetSecret = %q, want secret-from-cache", value) - } -} - -func TestBitwardenStoreCacheMissChecksReadinessLazily(t *testing.T) { - withBitwardenSession(t) - withBitwardenUserCacheDir(t, func() (string, error) { - return t.TempDir(), nil - }) - fakeCLI := newFakeBitwardenCLI("bw") - fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ - ID: "item-1", - Name: "email-mcp/api-token", - Secret: "secret-v1", - MarkerService: "email-mcp", - MarkerSecretName: "api-token", - } - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - if fakeCLI.statusChecked { - t.Fatal("Open should not check bitwarden status") - } - - value, err := store.GetSecret("api-token") - if err != nil { - t.Fatalf("GetSecret returned error: %v", err) - } - if value != "secret-v1" { - t.Fatalf("GetSecret = %q, want secret-v1", value) - } - if !fakeCLI.statusChecked { - t.Fatal("cache miss should check bitwarden status before CLI lookup") - } -} - -func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) { - withBitwardenSession(t) - t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0") - fakeCLI := newFakeBitwardenCLI("bw") - fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ - ID: "item-1", - Name: "email-mcp/api-token", - Secret: "secret-v1", - MarkerService: "email-mcp", - MarkerSecretName: "api-token", - } - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - for i := 0; i < 2; i++ { - if _, err := store.GetSecret("api-token"); err != nil { - t.Fatalf("GetSecret #%d returned error: %v", i+1, err) - } - } - if fakeCLI.getItemCalls != 2 { - t.Fatalf("bw get item count = %d, want 2 when env disables cache", fakeCLI.getItemCalls) - } -} - -func TestBitwardenStoreSetSecretRefreshesCache(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { - t.Fatalf("SetSecret v1 returned error: %v", err) - } - if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v1" { - t.Fatalf("GetSecret after v1 = %q, %v; want secret-v1, nil", got, err) - } - if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil { - t.Fatalf("SetSecret v2 returned error: %v", err) - } - if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v2" { - t.Fatalf("GetSecret after v2 = %q, %v; want secret-v2, nil", got, err) - } -} - -func TestBitwardenStoreDeleteSecretInvalidatesCache(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - withBitwardenRunner(t, fakeCLI.run) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { - t.Fatalf("SetSecret returned error: %v", err) - } - if _, err := store.GetSecret("api-token"); err != nil { - t.Fatalf("GetSecret before delete returned error: %v", err) - } - if err := store.DeleteSecret("api-token"); err != nil { - t.Fatalf("DeleteSecret returned error: %v", err) - } - _, err = store.GetSecret("api-token") - if !errors.Is(err, ErrNotFound) { - t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err) - } -} - -func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) { - store := &bitwardenStore{command: "bw", serviceName: "email-mcp"} - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" { - return []byte(""), nil - } - return nil, fmt.Errorf("unexpected args: %v", args) - }) - - _, _, err := store.findItem("email-mcp/api-token", "api-token") - if !errors.Is(err, ErrNotFound) { - t.Fatalf("error = %v, want ErrNotFound", err) - } -} - -func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) { - if _, err := exec.LookPath("sh"); err != nil { - t.Skip("sh is required for this test") - } - - _, err := executeBitwardenCLI("sh", nil, "-c", "echo 'You are not logged in.' 1>&2; echo ' at Foo (node:internal/x)' 1>&2; exit 1") - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, ErrBWNotLoggedIn) { - t.Fatalf("error = %v, want ErrBWNotLoggedIn", err) - } - if strings.Contains(err.Error(), "node:internal") { - t.Fatalf("error = %v, stack trace should be stripped", err) - } -} - -func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) { - withBitwardenSession(t) - withBitwardenUserCacheDir(t, func() (string, error) { - return t.TempDir(), nil - }) - fakeCLI := newFakeBitwardenCLI("bw") - fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ - ID: "item-1", - Name: "email-mcp/api-token", - Secret: "secret-v1", - MarkerService: "email-mcp", - MarkerSecretName: "api-token", - } - withBitwardenRunner(t, fakeCLI.run) - t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1") - - var logs bytes.Buffer - withBitwardenDebugOutput(t, &logs) - - store, err := Open(Options{ - ServiceName: "email-mcp", - BackendPolicy: BackendBitwardenCLI, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - if _, err := store.GetSecret("api-token"); err != nil { - t.Fatalf("GetSecret returned error: %v", err) - } - - text := logs.String() - if !strings.Contains(text, "bw --version") { - t.Fatalf("debug logs = %q, want command bw --version", text) - } - if !strings.Contains(text, "bw status") { - t.Fatalf("debug logs = %q, want command bw status", text) - } -} - -func TestBitwardenDebugRedactsSensitivePayloadArguments(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - withBitwardenRunner(t, fakeCLI.run) - - var logs bytes.Buffer - withBitwardenDebugOutput(t, &logs) - - store, err := Open(Options{ - ServiceName: "graylog-mcp", - BackendPolicy: BackendBitwardenCLI, - BitwardenDebug: true, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { - t.Fatalf("SetSecret returned error: %v", err) - } - - text := logs.String() - if !strings.Contains(text, "bw create item ") { - t.Fatalf("debug logs = %q, want redacted create payload", text) - } -} - -func TestBitwardenLoaderFrameUsesSingleLineRewriteAndMessage(t *testing.T) { - frame := bitwardenLoaderFrame(0) - if !strings.HasPrefix(frame, "\r\033[2K") { - t.Fatalf("frame prefix = %q, want carriage return + clear line", frame) - } - if !strings.Contains(frame, "\033[38;5;117mW") { - t.Fatalf("frame = %q, want highlighted first rune", frame) - } - cleaned := stripANSIControlSequences(frame) - if cleaned != bitwardenLoaderMessage { - t.Fatalf("cleaned frame = %q, want %q", cleaned, bitwardenLoaderMessage) - } -} - -func TestBitwardenLoaderFrameMovesAndWrapsTheWave(t *testing.T) { - firstFrame := bitwardenLoaderFrame(0) - secondFrame := bitwardenLoaderFrame(1) - if firstFrame == secondFrame { - t.Fatal("expected different frames between two ticks") - } - if !strings.Contains(secondFrame, "\033[38;5;117ma") { - t.Fatalf("second frame = %q, want highlighted second rune", secondFrame) - } - - wrapped := bitwardenLoaderFrame(utf8.RuneCountInString(bitwardenLoaderMessage)) - if !strings.Contains(wrapped, "\033[38;5;117mW") { - t.Fatalf("wrapped frame = %q, want wave to wrap to first rune", wrapped) - } -} - -func TestExecuteBitwardenCLIInteractiveSkipsLoader(t *testing.T) { - loaderStartCount := 0 - withBitwardenLoaderStarter(t, func() func() { - loaderStartCount++ - return func() {} - }) - - _, err := executeBitwardenCLIInteractive( - os.Args[0], - nil, - io.Discard, - io.Discard, - "-test.run=^$", - ) - if err != nil { - t.Fatalf("executeBitwardenCLIInteractive returned error: %v", err) - } - if loaderStartCount != 0 { - t.Fatalf("loader start count = %d, want 0 for interactive command", loaderStartCount) - } -} - -var ansiControlSequencePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) - -func stripANSIControlSequences(value string) string { - noANSI := ansiControlSequencePattern.ReplaceAllString(value, "") - return strings.ReplaceAll(noANSI, "\r", "") -} - -func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - withBitwardenRunner(t, fakeCLI.run) - - store, err := OpenFromManifest(OpenFromManifestOptions{ - ServiceName: "email-mcp", - ExecutableResolver: func() (string, error) { - return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil - }, - ManifestLoader: func(startDir string) (manifest.File, string, error) { - return manifest.File{ - SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)}, - }, filepath.Join(startDir, manifest.DefaultFile), nil - }, - }) - if err != nil { - t.Fatalf("OpenFromManifest returned error: %v", err) - } - - if err := store.SetSecret("smtp-password", "SMTP password", "super-secret"); err != nil { - t.Fatalf("SetSecret returned error: %v", err) - } - - value, err := store.GetSecret("smtp-password") - if err != nil { - t.Fatalf("GetSecret returned error: %v", err) - } - if value != "super-secret" { - t.Fatalf("GetSecret = %q, want super-secret", value) - } -} - -func withBitwardenSession(t *testing.T) { - t.Helper() - sessionName := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name()) - t.Setenv("BW_SESSION", "test-session-"+sessionName) - withBitwardenUserCacheDir(t, func() (string, error) { - return t.TempDir(), nil - }) -} - -func withBitwardenRunner( - t *testing.T, - runner func(command string, stdin []byte, args ...string) ([]byte, error), -) { - t.Helper() - - previous := runBitwardenCLI - runBitwardenCLI = runner - t.Cleanup(func() { - runBitwardenCLI = previous - }) -} - -func withBitwardenDebugOutput(t *testing.T, writer io.Writer) { - t.Helper() - - previous := bitwardenDebugOutput - bitwardenDebugOutput = writer - t.Cleanup(func() { - bitwardenDebugOutput = previous - }) -} - -func withBitwardenLoaderStarter(t *testing.T, starter func() func()) { - t.Helper() - - previous := startBitwardenLoaderFunc - startBitwardenLoaderFunc = starter - t.Cleanup(func() { - startBitwardenLoaderFunc = previous - }) -} - -type fakeBitwardenCLI struct { - command string - itemsByID map[string]fakeBitwardenItem - nextID int - status string - versionChecked bool - statusChecked bool - getItemCalls int -} - -type fakeBitwardenItem struct { - ID string - Name string - Notes string - Secret string - MarkerService string - MarkerSecretName string -} - -func newFakeBitwardenCLI(command string) *fakeBitwardenCLI { - return &fakeBitwardenCLI{ - command: strings.TrimSpace(command), - itemsByID: map[string]fakeBitwardenItem{}, - nextID: 1, - status: "unlocked", - } -} - -func (f *fakeBitwardenCLI) run(command string, stdin []byte, args ...string) ([]byte, error) { - if strings.TrimSpace(command) != f.command { - return nil, fmt.Errorf("unexpected command %q", command) - } - if len(args) == 0 { - return nil, errors.New("missing bitwarden CLI arguments") - } - - if len(args) == 1 && args[0] == "--version" { - f.versionChecked = true - return []byte("2026.1.0\n"), nil - } - if len(args) == 1 && args[0] == "status" { - f.statusChecked = true - return []byte(fmt.Sprintf(`{"status":%q}`, strings.TrimSpace(f.status))), nil - } - - switch { - case len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search": - return f.handleListItems(args[3]) - case len(args) == 3 && args[0] == "get" && args[1] == "item": - f.getItemCalls++ - return f.handleGetItem(args[2]) - case len(args) == 3 && args[0] == "get" && args[1] == "template" && args[2] == "item": - return []byte(`{"type":2,"name":"","notes":"","secureNote":{"type":0},"fields":[]}`), nil - case len(args) == 1 && args[0] == "encode": - return []byte(base64.StdEncoding.EncodeToString(stdin)), nil - case len(args) == 3 && args[0] == "create" && args[1] == "item": - return f.handleCreateItem(args[2]) - case len(args) == 4 && args[0] == "edit" && args[1] == "item": - return f.handleEditItem(args[2], args[3]) - case len(args) == 3 && args[0] == "delete" && args[1] == "item": - delete(f.itemsByID, strings.TrimSpace(args[2])) - return []byte(`{"success":true}`), nil - default: - return nil, fmt.Errorf("unsupported bitwarden CLI invocation: %v", args) - } -} - -func (f *fakeBitwardenCLI) handleListItems(search string) ([]byte, error) { - needle := strings.TrimSpace(search) - items := make([]map[string]any, 0) - for _, item := range f.itemsByID { - if !strings.Contains(item.Name, needle) { - continue - } - items = append(items, map[string]any{ - "id": item.ID, - "name": item.Name, - "notes": item.Notes, - "fields": item.fieldsPayload(), - }) - } - - payload, err := json.Marshal(items) - if err != nil { - return nil, err - } - return payload, nil -} - -func (f *fakeBitwardenCLI) handleGetItem(id string) ([]byte, error) { - item, ok := f.itemsByID[strings.TrimSpace(id)] - if !ok { - return nil, errors.New("item not found") - } - - payload, err := json.Marshal(map[string]any{ - "id": item.ID, - "name": item.Name, - "notes": item.Notes, - "fields": item.fieldsPayload(), - "secureNote": map[string]any{"type": 0}, - }) - if err != nil { - return nil, err - } - - return payload, nil -} - -func (f *fakeBitwardenCLI) handleCreateItem(encoded string) ([]byte, error) { - payload, err := decodeBitwardenPayload(encoded) - if err != nil { - return nil, err - } - - item := fakeBitwardenItem{ - ID: fmt.Sprintf("item-%d", f.nextID), - Name: readString(payload, "name"), - Notes: readString(payload, "notes"), - Secret: readFakeBitwardenField(payload, bitwardenSecretFieldName), - MarkerService: readFakeBitwardenField(payload, bitwardenServiceFieldName), - MarkerSecretName: readFakeBitwardenField(payload, bitwardenSecretNameFieldName), - } - f.nextID++ - f.itemsByID[item.ID] = item - - payload["id"] = item.ID - encodedPayload, err := json.Marshal(payload) - if err != nil { - return nil, err - } - return encodedPayload, nil -} - -func (f *fakeBitwardenCLI) handleEditItem(id, encoded string) ([]byte, error) { - trimmedID := strings.TrimSpace(id) - if _, ok := f.itemsByID[trimmedID]; !ok { - return nil, errors.New("item not found") - } - - payload, err := decodeBitwardenPayload(encoded) - if err != nil { - return nil, err - } - - item := fakeBitwardenItem{ - ID: trimmedID, - Name: readString(payload, "name"), - Notes: readString(payload, "notes"), - Secret: readFakeBitwardenField(payload, bitwardenSecretFieldName), - MarkerService: readFakeBitwardenField(payload, bitwardenServiceFieldName), - MarkerSecretName: readFakeBitwardenField(payload, bitwardenSecretNameFieldName), - } - f.itemsByID[trimmedID] = item - - payload["id"] = trimmedID - encodedPayload, err := json.Marshal(payload) - if err != nil { - return nil, err - } - return encodedPayload, nil -} - -func decodeBitwardenPayload(encoded string) (map[string]any, error) { - raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) - if err != nil { - return nil, fmt.Errorf("decode encoded payload: %w", err) - } - - var payload map[string]any - if err := json.Unmarshal(raw, &payload); err != nil { - return nil, fmt.Errorf("decode payload JSON: %w", err) - } - - return payload, nil -} - -func readFakeBitwardenField(payload map[string]any, fieldName string) string { - rawFields, ok := payload["fields"] - if !ok { - return "" - } - - fields, ok := rawFields.([]any) - if !ok { - return "" - } - - for _, rawField := range fields { - field, ok := rawField.(map[string]any) - if !ok { - continue - } - - name := strings.TrimSpace(readString(field, "name")) - if name != fieldName { - continue - } - - return readString(field, "value") - } - - return "" -} - -func (i fakeBitwardenItem) fieldsPayload() []map[string]any { - fields := []map[string]any{ - {"name": bitwardenSecretFieldName, "value": i.Secret, "type": 1}, - } - - if strings.TrimSpace(i.MarkerService) != "" { - fields = append(fields, map[string]any{"name": bitwardenServiceFieldName, "value": i.MarkerService, "type": 0}) - } - if strings.TrimSpace(i.MarkerSecretName) != "" { - fields = append(fields, map[string]any{"name": bitwardenSecretNameFieldName, "value": i.MarkerSecretName, "type": 0}) - } - - return fields -} - -func readString(payload map[string]any, key string) string { - value, _ := payload[key].(string) - return strings.TrimSpace(value) -} diff --git a/secretstore/manifest_open.go b/secretstore/manifest_open.go deleted file mode 100644 index 4b0051b..0000000 --- a/secretstore/manifest_open.go +++ /dev/null @@ -1,127 +0,0 @@ -package secretstore - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" - - "gitea.lclr.dev/AI/mcp-framework/manifest" -) - -type ManifestLoader func(startDir string) (manifest.File, string, error) - -type ExecutableResolver func() (string, error) - -type OpenFromManifestOptions struct { - ServiceName string - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string - BitwardenCommand string - BitwardenDebug bool - DisableBitwardenCache bool - Shell string - ManifestLoader ManifestLoader - ExecutableResolver ExecutableResolver -} - -func OpenFromManifest(options OpenFromManifestOptions) (Store, error) { - manifestPolicy, err := resolveManifestPolicy(options) - if err != nil { - return nil, err - } - - return Open(Options{ - ServiceName: options.ServiceName, - BackendPolicy: manifestPolicy.Policy, - LookupEnv: options.LookupEnv, - KWalletAppID: options.KWalletAppID, - KWalletFolder: options.KWalletFolder, - BitwardenCommand: strings.TrimSpace(options.BitwardenCommand), - BitwardenDebug: options.BitwardenDebug, - DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, manifestPolicy.BitwardenCache), - Shell: strings.TrimSpace(options.Shell), - }) -} - -func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolicy, error) { - resolution, err := resolveManifestPolicy(options) - if err != nil { - return "", err - } - return resolution.Policy, nil -} - -type manifestPolicyResolution struct { - Policy BackendPolicy - Source string - BitwardenCache bool -} - -func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) { - manifestLoader := options.ManifestLoader - if manifestLoader == nil { - manifestLoader = manifest.LoadDefault - } - - executableResolver := options.ExecutableResolver - if executableResolver == nil { - executableResolver = os.Executable - } - - executablePath, err := executableResolver() - if err != nil { - return manifestPolicyResolution{}, fmt.Errorf("resolve executable path for manifest lookup: %w", err) - } - - startDir := filepath.Dir(strings.TrimSpace(executablePath)) - if strings.TrimSpace(startDir) == "" { - startDir = "." - } - - file, manifestPath, err := manifestLoader(startDir) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return manifestPolicyResolution{ - Policy: BackendAuto, - Source: "", - BitwardenCache: true, - }, nil - } - return manifestPolicyResolution{}, fmt.Errorf("load runtime manifest from %q: %w", startDir, err) - } - - bitwardenCache := true - if file.SecretStore.BitwardenCache != nil { - bitwardenCache = *file.SecretStore.BitwardenCache - } - - if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" { - return manifestPolicyResolution{ - Policy: BackendAuto, - Source: strings.TrimSpace(manifestPath), - BitwardenCache: bitwardenCache, - }, nil - } - - policy, err := normalizeBackendPolicy(BackendPolicy(file.SecretStore.BackendPolicy)) - if err != nil { - return manifestPolicyResolution{}, fmt.Errorf( - "invalid secret_store.backend_policy in manifest %q: %w", - strings.TrimSpace(manifestPath), - err, - ) - } - - return manifestPolicyResolution{ - Policy: policy, - Source: strings.TrimSpace(manifestPath), - BitwardenCache: bitwardenCache, - }, nil -} - -func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool { - return runtimeDisabled || !manifestEnabled -} diff --git a/secretstore/manifest_open_test.go b/secretstore/manifest_open_test.go deleted file mode 100644 index c60e8c6..0000000 --- a/secretstore/manifest_open_test.go +++ /dev/null @@ -1,211 +0,0 @@ -package secretstore - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/99designs/keyring" - - "gitea.lclr.dev/AI/mcp-framework/manifest" -) - -func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) { - var gotStartDir string - - store, err := OpenFromManifest(OpenFromManifestOptions{ - ServiceName: "email-mcp", - LookupEnv: func(name string) (string, bool) { - if name == "EMAIL_TOKEN" { - return "from-env", true - } - return "", false - }, - ExecutableResolver: func() (string, error) { - return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil - }, - ManifestLoader: func(startDir string) (manifest.File, string, error) { - gotStartDir = startDir - return manifest.File{ - SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)}, - }, filepath.Join(startDir, manifest.DefaultFile), nil - }, - }) - if err != nil { - t.Fatalf("OpenFromManifest returned error: %v", err) - } - - wantDir := filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin") - if gotStartDir != wantDir { - t.Fatalf("manifest loader startDir = %q, want %q", gotStartDir, wantDir) - } - - value, err := store.GetSecret("EMAIL_TOKEN") - if err != nil { - t.Fatalf("GetSecret returned error: %v", err) - } - if value != "from-env" { - t.Fatalf("GetSecret = %q, want from-env", value) - } -} - -func TestOpenFromManifestFallsBackToAutoWhenManifestIsMissing(t *testing.T) { - withKeyringHooks(t, nil, func(cfg keyring.Config) (keyring.Keyring, error) { - t.Fatal("unexpected keyring open call") - return nil, nil - }) - - store, err := OpenFromManifest(OpenFromManifestOptions{ - ServiceName: "email-mcp", - LookupEnv: func(name string) (string, bool) { - if name == "EMAIL_TOKEN" { - return "env-token", true - } - return "", false - }, - ExecutableResolver: func() (string, error) { - return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil - }, - ManifestLoader: func(string) (manifest.File, string, error) { - return manifest.File{}, "", os.ErrNotExist - }, - }) - if err != nil { - t.Fatalf("OpenFromManifest returned error: %v", err) - } - - value, err := store.GetSecret("EMAIL_TOKEN") - if err != nil { - t.Fatalf("GetSecret returned error: %v", err) - } - if value != "env-token" { - t.Fatalf("GetSecret = %q, want env-token", value) - } -} - -func TestOpenFromManifestReturnsExplicitErrorForInvalidManifestPolicy(t *testing.T) { - _, err := OpenFromManifest(OpenFromManifestOptions{ - ServiceName: "email-mcp", - ExecutableResolver: func() (string, error) { - return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil - }, - ManifestLoader: func(startDir string) (manifest.File, string, error) { - return manifest.File{ - SecretStore: manifest.SecretStore{BackendPolicy: "totally-invalid"}, - }, filepath.Join(startDir, manifest.DefaultFile), nil - }, - }) - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, ErrInvalidBackendPolicy) { - t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err) - } - if !strings.Contains(err.Error(), "secret_store.backend_policy") { - t.Fatalf("error = %v, want manifest policy context", err) - } - if !strings.Contains(err.Error(), manifest.DefaultFile) { - t.Fatalf("error = %v, want manifest path", err) - } -} - -func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) { - cacheDisabled := false - resolution, err := resolveManifestPolicy(OpenFromManifestOptions{ - ServiceName: "email-mcp", - ExecutableResolver: func() (string, error) { - return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil - }, - ManifestLoader: func(startDir string) (manifest.File, string, error) { - return manifest.File{ - SecretStore: manifest.SecretStore{ - BackendPolicy: string(BackendBitwardenCLI), - BitwardenCache: &cacheDisabled, - }, - }, filepath.Join(startDir, manifest.DefaultFile), nil - }, - }) - if err != nil { - t.Fatalf("resolveManifestPolicy returned error: %v", err) - } - if resolution.BitwardenCache { - t.Fatal("resolution BitwardenCache = true, want false") - } - if resolution.Policy != BackendBitwardenCLI { - t.Fatalf("resolution policy = %q, want %q", resolution.Policy, BackendBitwardenCLI) - } -} - -func TestOpenFromManifestAppliesBitwardenCacheDisable(t *testing.T) { - withBitwardenSession(t) - fakeCLI := newFakeBitwardenCLI("bw") - fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ - ID: "item-1", - Name: "email-mcp/api-token", - Secret: "secret-v1", - MarkerService: "email-mcp", - MarkerSecretName: "api-token", - } - withBitwardenRunner(t, fakeCLI.run) - - cacheDisabled := false - store, err := OpenFromManifest(OpenFromManifestOptions{ - ServiceName: "email-mcp", - ExecutableResolver: func() (string, error) { - return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil - }, - ManifestLoader: func(startDir string) (manifest.File, string, error) { - return manifest.File{ - SecretStore: manifest.SecretStore{ - BackendPolicy: string(BackendBitwardenCLI), - BitwardenCache: &cacheDisabled, - }, - }, filepath.Join(startDir, manifest.DefaultFile), nil - }, - }) - if err != nil { - t.Fatalf("OpenFromManifest returned error: %v", err) - } - - for i := 0; i < 2; i++ { - value, err := store.GetSecret("api-token") - if err != nil { - t.Fatalf("GetSecret #%d returned error: %v", i+1, err) - } - if value != "secret-v1" { - t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) - } - } - if fakeCLI.getItemCalls != 2 { - t.Fatalf("bw get item count = %d, want 2 when manifest disables cache", fakeCLI.getItemCalls) - } -} - -func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) { - execErr := errors.New("boom") - _, err := OpenFromManifest(OpenFromManifestOptions{ - ExecutableResolver: func() (string, error) { - return "", execErr - }, - }) - if !errors.Is(err, execErr) { - t.Fatalf("error = %v, want wrapped executable resolver error", err) - } -} - -func TestOpenFromManifestReturnsManifestLoaderError(t *testing.T) { - loadErr := errors.New("cannot parse manifest") - _, err := OpenFromManifest(OpenFromManifestOptions{ - ExecutableResolver: func() (string, error) { - return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil - }, - ManifestLoader: func(string) (manifest.File, string, error) { - return manifest.File{}, "", loadErr - }, - }) - if !errors.Is(err, loadErr) { - t.Fatalf("error = %v, want wrapped manifest loader error", err) - } -} diff --git a/secretstore/runtime.go b/secretstore/runtime.go deleted file mode 100644 index 08b93ab..0000000 --- a/secretstore/runtime.go +++ /dev/null @@ -1,214 +0,0 @@ -package secretstore - -import ( - "errors" - "fmt" - "strings" -) - -const DefaultManifestSource = "default:auto (manifest not found)" - -type DescribeRuntimeOptions struct { - ServiceName string - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string - BitwardenCommand string - BitwardenDebug bool - DisableBitwardenCache bool - CheckReady bool - Shell string - ManifestLoader ManifestLoader - ExecutableResolver ExecutableResolver -} - -type RuntimeDescription struct { - ManifestSource string - DeclaredPolicy BackendPolicy - EffectivePolicy BackendPolicy - DisplayName string - Ready bool - ReadyError error -} - -type PreflightStatus string - -const ( - PreflightStatusReady PreflightStatus = "ready" - PreflightStatusFail PreflightStatus = "fail" -) - -type PreflightOptions = DescribeRuntimeOptions - -type PreflightReport struct { - Status PreflightStatus - Summary string - Remediation string - Runtime RuntimeDescription -} - -func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) { - resolution, err := resolveManifestPolicy(OpenFromManifestOptions{ - ServiceName: options.ServiceName, - LookupEnv: options.LookupEnv, - KWalletAppID: options.KWalletAppID, - KWalletFolder: options.KWalletFolder, - BitwardenCommand: options.BitwardenCommand, - DisableBitwardenCache: options.DisableBitwardenCache, - Shell: options.Shell, - ManifestLoader: options.ManifestLoader, - ExecutableResolver: options.ExecutableResolver, - }) - if err != nil { - return RuntimeDescription{}, err - } - - desc := RuntimeDescription{ - ManifestSource: manifestSourceLabel(resolution.Source), - DeclaredPolicy: resolution.Policy, - EffectivePolicy: resolution.Policy, - DisplayName: BackendDisplayName(resolution.Policy), - } - - store, openErr := Open(Options{ - ServiceName: options.ServiceName, - BackendPolicy: resolution.Policy, - LookupEnv: options.LookupEnv, - KWalletAppID: options.KWalletAppID, - KWalletFolder: options.KWalletFolder, - BitwardenCommand: options.BitwardenCommand, - BitwardenDebug: options.BitwardenDebug, - DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, resolution.BitwardenCache), - Shell: options.Shell, - }) - if openErr != nil { - desc.Ready = false - desc.ReadyError = openErr - return desc, nil - } - - desc.Ready = true - if effective := EffectiveBackendPolicy(store); strings.TrimSpace(string(effective)) != "" { - desc.EffectivePolicy = effective - desc.DisplayName = BackendDisplayName(effective) - } - if options.CheckReady && desc.EffectivePolicy == BackendBitwardenCLI { - if err := verifyBitwardenCLIReady(Options{ - BitwardenCommand: options.BitwardenCommand, - BitwardenDebug: options.BitwardenDebug, - LookupEnv: options.LookupEnv, - Shell: options.Shell, - }); err != nil { - desc.Ready = false - desc.ReadyError = err - } - } - - return desc, nil -} - -func PreflightFromManifest(options PreflightOptions) (PreflightReport, error) { - options.CheckReady = true - desc, err := DescribeRuntime(options) - if err != nil { - return PreflightReport{}, err - } - - if desc.Ready { - return PreflightReport{ - Status: PreflightStatusReady, - Summary: "secret backend is ready", - Runtime: desc, - }, nil - } - - summary, remediation := summarizePreflightFailure(desc.ReadyError) - return PreflightReport{ - Status: PreflightStatusFail, - Summary: summary, - Remediation: remediation, - Runtime: desc, - }, nil -} - -func BackendDisplayName(policy BackendPolicy) string { - switch policy { - case BackendBitwardenCLI: - return "Bitwarden CLI" - case BackendEnvOnly: - return "Environment variables" - case BackendKWalletOnly: - return "KWallet" - case BackendAuto: - return "automatic backend selection" - case BackendKeyringAny: - return BackendName() - default: - trimmed := strings.TrimSpace(string(policy)) - if trimmed == "" { - return "unknown backend" - } - return trimmed - } -} - -func FormatBackendStatus(desc RuntimeDescription) string { - source := manifestSourceLabel(desc.ManifestSource) - display := strings.TrimSpace(desc.DisplayName) - if display == "" { - display = BackendDisplayName(desc.EffectivePolicy) - } - - effective := desc.EffectivePolicy - if strings.TrimSpace(string(effective)) == "" { - effective = desc.DeclaredPolicy - } - - parts := []string{ - fmt.Sprintf("declared=%s", normalizeStatusPolicy(desc.DeclaredPolicy)), - fmt.Sprintf("effective=%s", normalizeStatusPolicy(effective)), - fmt.Sprintf("display=%s", display), - fmt.Sprintf("ready=%t", desc.Ready), - fmt.Sprintf("source=%s", source), - } - if desc.ReadyError != nil { - parts = append(parts, fmt.Sprintf("error=%s", strings.TrimSpace(desc.ReadyError.Error()))) - } - - return strings.Join(parts, " ") -} - -func summarizePreflightFailure(err error) (string, string) { - if err == nil { - return "secret backend is unavailable", "" - } - - switch { - case errors.Is(err, ErrBWNotLoggedIn): - return "bitwarden login is required", strings.TrimSpace(err.Error()) - case errors.Is(err, ErrBWLocked): - return "bitwarden vault is locked or BW_SESSION is missing", strings.TrimSpace(err.Error()) - case errors.Is(err, ErrBWUnavailable): - return "bitwarden CLI is unavailable", strings.TrimSpace(err.Error()) - case errors.Is(err, ErrBackendUnavailable): - return "secret backend is unavailable", strings.TrimSpace(err.Error()) - default: - return "secret backend preflight failed", strings.TrimSpace(err.Error()) - } -} - -func manifestSourceLabel(source string) string { - trimmed := strings.TrimSpace(source) - if trimmed == "" { - return DefaultManifestSource - } - return trimmed -} - -func normalizeStatusPolicy(policy BackendPolicy) string { - trimmed := strings.TrimSpace(string(policy)) - if trimmed == "" { - return string(BackendAuto) - } - return trimmed -} diff --git a/secretstore/runtime_test.go b/secretstore/runtime_test.go deleted file mode 100644 index 23f90f1..0000000 --- a/secretstore/runtime_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package secretstore - -import ( - "errors" - "path/filepath" - "strings" - "testing" - - "gitea.lclr.dev/AI/mcp-framework/manifest" -) - -func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) { - desc, err := DescribeRuntime(DescribeRuntimeOptions{ - ServiceName: "graylog-mcp", - LookupEnv: func(string) (string, bool) { return "", false }, - ExecutableResolver: func() (string, error) { - return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil - }, - ManifestLoader: func(startDir string) (manifest.File, string, error) { - return manifest.File{ - SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)}, - }, filepath.Join(startDir, manifest.DefaultFile), nil - }, - }) - if err != nil { - t.Fatalf("DescribeRuntime returned error: %v", err) - } - - if desc.ManifestSource == "" { - t.Fatal("ManifestSource should not be empty") - } - if desc.DeclaredPolicy != BackendEnvOnly { - t.Fatalf("DeclaredPolicy = %q, want %q", desc.DeclaredPolicy, BackendEnvOnly) - } - if desc.EffectivePolicy != BackendEnvOnly { - t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendEnvOnly) - } - if desc.DisplayName == "" { - t.Fatal("DisplayName should not be empty") - } - if !desc.Ready { - t.Fatalf("Ready = %v, want true", desc.Ready) - } - if desc.ReadyError != nil { - t.Fatalf("ReadyError = %v, want nil", desc.ReadyError) - } -} - -func TestDescribeRuntimeDoesNotProbeBitwardenByDefault(t *testing.T) { - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - return nil, errors.New("unexpected bitwarden invocation") - }) - - desc, err := DescribeRuntime(DescribeRuntimeOptions{ - ServiceName: "graylog-mcp", - Shell: "fish", - ExecutableResolver: func() (string, error) { - return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil - }, - ManifestLoader: func(startDir string) (manifest.File, string, error) { - return manifest.File{ - SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)}, - }, filepath.Join(startDir, manifest.DefaultFile), nil - }, - }) - if err != nil { - t.Fatalf("DescribeRuntime returned error: %v", err) - } - - if desc.DeclaredPolicy != BackendBitwardenCLI { - t.Fatalf("DeclaredPolicy = %q, want %q", desc.DeclaredPolicy, BackendBitwardenCLI) - } - if desc.EffectivePolicy != BackendBitwardenCLI { - t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendBitwardenCLI) - } - if !desc.Ready { - t.Fatalf("Ready = %v, want true without readiness probe", desc.Ready) - } - if desc.ReadyError != nil { - t.Fatalf("ReadyError = %v, want nil without readiness probe", desc.ReadyError) - } -} - -func TestPreflightFromManifestReturnsTypedStatusAndRemediation(t *testing.T) { - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - switch { - case len(args) == 1 && args[0] == "--version": - return []byte("2026.1.0\n"), nil - case len(args) == 1 && args[0] == "status": - return []byte(`{"status":"locked"}`), nil - default: - return nil, errors.New("unexpected bitwarden invocation") - } - }) - - report, err := PreflightFromManifest(PreflightOptions{ - ServiceName: "graylog-mcp", - Shell: "fish", - ExecutableResolver: func() (string, error) { - return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil - }, - ManifestLoader: func(startDir string) (manifest.File, string, error) { - return manifest.File{ - SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)}, - }, filepath.Join(startDir, manifest.DefaultFile), nil - }, - }) - if err != nil { - t.Fatalf("PreflightFromManifest returned error: %v", err) - } - - if report.Status != PreflightStatusFail { - t.Fatalf("Status = %q, want %q", report.Status, PreflightStatusFail) - } - if !strings.Contains(strings.ToLower(report.Summary), "locked") { - t.Fatalf("Summary = %q, want lock hint", report.Summary) - } - if !strings.Contains(report.Remediation, "set -x BW_SESSION (bw unlock --raw)") { - t.Fatalf("Remediation = %q, want fish remediation", report.Remediation) - } -} - -func TestFormatBackendStatusIncludesDeclaredEffectiveAndReadiness(t *testing.T) { - line := FormatBackendStatus(RuntimeDescription{ - ManifestSource: "/opt/graylog-mcp/mcp.toml", - DeclaredPolicy: BackendBitwardenCLI, - EffectivePolicy: BackendBitwardenCLI, - DisplayName: "Bitwarden CLI", - Ready: false, - ReadyError: ErrBWLocked, - }) - - for _, needle := range []string{ - "declared=bitwarden-cli", - "effective=bitwarden-cli", - "display=Bitwarden CLI", - "ready=false", - "source=/opt/graylog-mcp/mcp.toml", - "error=", - } { - if !strings.Contains(line, needle) { - t.Fatalf("line = %q, want substring %q", line, needle) - } - } -} diff --git a/secretstore/store.go b/secretstore/store.go index d88d3d2..3cb7e6f 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -15,31 +15,22 @@ import ( var ErrNotFound = errors.New("secret not found") var ErrBackendUnavailable = errors.New("secret backend unavailable") var ErrReadOnly = errors.New("secret backend is read-only") -var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy") -var ErrBWNotLoggedIn = errors.New("bitwarden is not logged in") -var ErrBWLocked = errors.New("bitwarden vault is locked or BW_SESSION is missing") -var ErrBWUnavailable = errors.New("bitwarden CLI unavailable") type BackendPolicy string const ( - BackendAuto BackendPolicy = "auto" - BackendKWalletOnly BackendPolicy = "kwallet-only" - BackendKeyringAny BackendPolicy = "keyring-any" - BackendEnvOnly BackendPolicy = "env-only" - BackendBitwardenCLI BackendPolicy = "bitwarden-cli" + BackendAuto BackendPolicy = "auto" + BackendKWalletOnly BackendPolicy = "kwallet-only" + BackendKeyringAny BackendPolicy = "keyring-any" + BackendEnvOnly BackendPolicy = "env-only" ) type Options struct { - ServiceName string - BackendPolicy BackendPolicy - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string - BitwardenCommand string - BitwardenDebug bool - DisableBitwardenCache bool - Shell string + ServiceName string + BackendPolicy BackendPolicy + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string } type Store interface { @@ -86,9 +77,15 @@ var ( ) func Open(options Options) (Store, error) { - policy, err := normalizeBackendPolicy(options.BackendPolicy) - if err != nil { - return nil, err + policy := options.BackendPolicy + if policy == "" { + policy = BackendAuto + } + + switch policy { + case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly: + default: + return nil, fmt.Errorf("invalid secret backend policy %q", policy) } if policy == BackendEnvOnly { @@ -100,10 +97,6 @@ func Open(options Options) (Store, error) { return nil, errors.New("service name must not be empty") } - if policy == BackendBitwardenCLI { - return newBitwardenStore(options, policy, serviceName) - } - available := availableKeyringPolicy() allowed, err := allowedBackends(policy, available) if err != nil { @@ -146,40 +139,6 @@ func BackendName() string { } } -func EffectiveBackendPolicy(store Store) BackendPolicy { - switch store.(type) { - case *bitwardenStore: - return BackendBitwardenCLI - case *envStore: - return BackendEnvOnly - case *keyringStore: - return BackendKeyringAny - default: - return "" - } -} - -func SetSecretVerified(store Store, name, label, secret string) error { - if store == nil { - return errors.New("secret store must not be nil") - } - - if err := store.SetSecret(name, label, secret); err != nil { - return err - } - - verified, err := store.GetSecret(name) - if err != nil { - return fmt.Errorf("verify secret %q after write: %w", name, err) - } - - if verified != secret { - return fmt.Errorf("verify secret %q after write: read-back mismatch", name) - } - - return nil -} - func SetJSON[T any](store Store, name, label string, value T) error { data, err := json.Marshal(value) if err != nil { @@ -290,7 +249,7 @@ func allowedBackends(policy BackendPolicy, available []keyring.BackendType) ([]k } return []keyring.BackendType{keyring.KWalletBackend}, nil default: - return nil, invalidBackendPolicyError(policy) + return nil, fmt.Errorf("invalid secret backend policy %q", policy) } } @@ -301,21 +260,3 @@ func backendNames(backends []keyring.BackendType) []string { } return names } - -func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) { - trimmed := BackendPolicy(strings.TrimSpace(string(policy))) - if trimmed == "" { - return BackendAuto, nil - } - - switch trimmed { - case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly, BackendBitwardenCLI: - return trimmed, nil - default: - return "", invalidBackendPolicyError(trimmed) - } -} - -func invalidBackendPolicyError(policy BackendPolicy) error { - return fmt.Errorf("%w %q", ErrInvalidBackendPolicy, policy) -} diff --git a/secretstore/store_test.go b/secretstore/store_test.go index 1ddd6c6..c7a2f68 100644 --- a/secretstore/store_test.go +++ b/secretstore/store_test.go @@ -80,9 +80,6 @@ func TestOpenRejectsInvalidPolicy(t *testing.T) { if err == nil { t.Fatal("expected error") } - if !errors.Is(err, ErrInvalidBackendPolicy) { - t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err) - } } func TestOpenAutoUsesAvailableKeyringBackends(t *testing.T) { @@ -248,88 +245,3 @@ func TestJSONHelpersRoundTrip(t *testing.T) { t.Fatalf("GetJSON = %#v, want %#v", output, input) } } - -func TestEffectiveBackendPolicyReportsConcreteBackend(t *testing.T) { - t.Run("env-only", func(t *testing.T) { - store, err := Open(Options{ - BackendPolicy: BackendEnvOnly, - LookupEnv: func(string) (string, bool) { return "", false }, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - if got := EffectiveBackendPolicy(store); got != BackendEnvOnly { - t.Fatalf("EffectiveBackendPolicy = %q, want %q", got, BackendEnvOnly) - } - }) - - t.Run("keyring", func(t *testing.T) { - withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) { - return &stubKeyring{}, nil - }) - - store, err := Open(Options{ - ServiceName: "mcp-framework-test", - BackendPolicy: BackendAuto, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - if got := EffectiveBackendPolicy(store); got != BackendKeyringAny { - t.Fatalf("EffectiveBackendPolicy = %q, want %q", got, BackendKeyringAny) - } - }) -} - -func TestSetSecretVerifiedWritesThenReadsBack(t *testing.T) { - ring := &stubKeyring{} - withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) { - return ring, nil - }) - - store, err := Open(Options{ - ServiceName: "mcp-framework-test", - BackendPolicy: BackendAuto, - }) - if err != nil { - t.Fatalf("Open returned error: %v", err) - } - - if err := SetSecretVerified(store, "token", "API token", "secret-value"); err != nil { - t.Fatalf("SetSecretVerified returned error: %v", err) - } -} - -func TestSetSecretVerifiedFailsOnReadBackMismatch(t *testing.T) { - store := &mismatchSecretStore{} - - err := SetSecretVerified(store, "token", "API token", "secret-value") - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, ErrNotFound) { - t.Fatalf("error = %v, want wrapped ErrNotFound", err) - } - if store.setCalls != 1 { - t.Fatalf("setCalls = %d, want 1", store.setCalls) - } -} - -type mismatchSecretStore struct { - setCalls int -} - -func (s *mismatchSecretStore) SetSecret(name, label, secret string) error { - s.setCalls++ - return nil -} - -func (s *mismatchSecretStore) GetSecret(name string) (string, error) { - return "", ErrNotFound -} - -func (s *mismatchSecretStore) DeleteSecret(name string) error { - return nil -} diff --git a/update/update.go b/update/update.go index 31c60f5..12593d8 100644 --- a/update/update.go +++ b/update/update.go @@ -1,12 +1,7 @@ package update import ( - "bytes" "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -14,64 +9,31 @@ import ( "net/http" "net/url" "os" - "path" "path/filepath" "runtime" - "sort" "strings" "time" ) -const defaultAssetNameTemplate = "{binary}-{os}-{arch}{ext}" -const defaultMaxDownloadBytes int64 = 200 * 1024 * 1024 -const downloadedArtifactSniffBytes = 4096 - type Options struct { - Client *http.Client - CurrentVersion string - ExecutablePath string - LatestReleaseURL string - Stdout io.Writer - BinaryName string - AssetNameTemplate string - ReleaseSource ReleaseSource - GOOS string - GOARCH string - MaxDownloadBytes int64 - ValidateDownloaded ValidateDownloadedFunc - ReplaceExecutable ReplaceExecutableFunc -} - -type ReplaceExecutableFunc func(downloadPath, targetPath string) error - -type ValidateDownloadedFunc func(context.Context, ValidationInput) error - -type ValidationInput struct { - DownloadPath string - TargetPath string - AssetName string - ReleaseTag string - ReleaseURL string - Source ReleaseSource + Client *http.Client + CurrentVersion string + ExecutablePath string + LatestReleaseURL string + Stdout io.Writer + BinaryName string + ReleaseSource ReleaseSource + GOOS string + GOARCH string } type ReleaseSource struct { - Name string - Driver string - Repository string - BaseURL string - LatestReleaseURL string - AssetNameTemplate string - ChecksumAssetName string - ChecksumRequired bool - SignatureAssetName string - SignatureRequired bool - SignaturePublicKey string - SignaturePublicKeyEnvNames []string - Token string - TokenHeader string - TokenPrefix string - TokenEnvNames []string + Name string + BaseURL string + LatestReleaseURL string + Token string + TokenHeader string + TokenEnvNames []string } type Auth struct { @@ -91,33 +53,6 @@ type ReleaseLink struct { URL string `json:"url"` } -type releasePayload struct { - TagName string `json:"tag_name"` - Assets json.RawMessage `json:"assets"` -} - -type releaseAssetsPayload struct { - Links []releaseLinkPayload `json:"links"` -} - -type releaseLinkPayload struct { - Name string `json:"name"` - URL string `json:"url"` - BrowserDownloadURL string `json:"browser_download_url"` - DirectAssetURL string `json:"direct_asset_url"` -} - -func (r *Release) UnmarshalJSON(data []byte) error { - var payload releasePayload - if err := json.Unmarshal(data, &payload); err != nil { - return err - } - - r.TagName = strings.TrimSpace(payload.TagName) - r.Assets.Links = parseReleaseLinks(payload.Assets) - return nil -} - func Run(ctx context.Context, opts Options) error { if opts.Stdout == nil { opts.Stdout = io.Discard @@ -134,16 +69,16 @@ func Run(ctx context.Context, opts Options) error { if strings.TrimSpace(opts.GOARCH) == "" { opts.GOARCH = runtime.GOARCH } - if opts.MaxDownloadBytes <= 0 { - opts.MaxDownloadBytes = defaultMaxDownloadBytes - } source := normalizeSource(opts.ReleaseSource) auth := ResolveAuth(source.Token, source) - releaseURL, err := ResolveLatestReleaseURL(opts.LatestReleaseURL, source) - if err != nil { - return err + releaseURL := opts.LatestReleaseURL + if strings.TrimSpace(releaseURL) == "" { + releaseURL = strings.TrimSpace(source.LatestReleaseURL) + } + if releaseURL == "" { + return errors.New("latest release URL must not be empty") } targetPath, err := ResolveUpdateTarget(opts.ExecutablePath) @@ -151,12 +86,7 @@ func Run(ctx context.Context, opts Options) error { return err } - assetTemplate := strings.TrimSpace(opts.AssetNameTemplate) - if assetTemplate == "" { - assetTemplate = source.AssetNameTemplate - } - - assetName, err := AssetNameWithTemplate(opts.BinaryName, opts.GOOS, opts.GOARCH, assetTemplate) + assetName, err := AssetName(opts.BinaryName, opts.GOOS, opts.GOARCH) if err != nil { return err } @@ -175,41 +105,13 @@ func Run(ctx context.Context, opts Options) error { return err } - downloadPath, err := DownloadReleaseAsset(ctx, opts.Client, assetURL, targetPath, auth, source, opts.MaxDownloadBytes) + downloadPath, err := DownloadReleaseAsset(ctx, opts.Client, assetURL, targetPath, auth, source) if err != nil { return err } defer os.Remove(downloadPath) - if err := validateDownloadedArtifact(downloadPath, assetName); err != nil { - return err - } - - if err := VerifyReleaseAssetChecksum(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil { - return err - } - if err := VerifyReleaseAssetSignature(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil { - return err - } - - if opts.ValidateDownloaded != nil { - if err := opts.ValidateDownloaded(ctx, ValidationInput{ - DownloadPath: downloadPath, - TargetPath: targetPath, - AssetName: assetName, - ReleaseTag: release.TagName, - ReleaseURL: releaseURL, - Source: source, - }); err != nil { - return fmt.Errorf("validate downloaded artifact: %w", err) - } - } - - replaceExecutable := opts.ReplaceExecutable - if replaceExecutable == nil { - replaceExecutable = ReplaceExecutable - } - if err := replaceExecutable(downloadPath, targetPath); err != nil { + if err := ReplaceExecutable(downloadPath, targetPath); err != nil { return err } @@ -217,101 +119,16 @@ func Run(ctx context.Context, opts Options) error { return nil } -func validateDownloadedArtifact(path, assetName string) error { - file, err := os.Open(path) - if err != nil { - return fmt.Errorf("validate downloaded artifact %q: %w", path, err) - } - defer file.Close() - - head := make([]byte, downloadedArtifactSniffBytes) - n, readErr := file.Read(head) - if readErr != nil && !errors.Is(readErr, io.EOF) { - return fmt.Errorf("validate downloaded artifact %q: %w", path, readErr) - } - if n == 0 { - return fmt.Errorf("downloaded artifact %q is empty", assetName) - } - - if looksLikeHTMLDocument(head[:n]) { - return fmt.Errorf( - "downloaded artifact %q looks like an HTML page (possible auth/forbidden response)", - assetName, - ) - } - - return nil -} - -func looksLikeHTMLDocument(content []byte) bool { - if len(content) == 0 { - return false - } - - detectedContentType := strings.ToLower(http.DetectContentType(content)) - if strings.HasPrefix(detectedContentType, "text/html") { - return true - } - - trimmed := bytes.TrimSpace(content) - trimmed = bytes.TrimPrefix(trimmed, []byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM - if len(trimmed) == 0 { - return false - } - - lower := strings.ToLower(string(trimmed)) - return strings.HasPrefix(lower, " 8 { - preview = preview[:8] - } - return "", fmt.Errorf( - "latest release does not contain asset %q (available: %s)", - assetName, - strings.Join(preview, ", "), - ) + return "", fmt.Errorf("latest release does not contain asset %q", assetName) } -func DownloadReleaseAsset( - ctx context.Context, - client *http.Client, - assetURL, targetPath string, - auth Auth, - source ReleaseSource, - maxDownloadBytes int64, -) (string, error) { - if maxDownloadBytes <= 0 { - maxDownloadBytes = defaultMaxDownloadBytes - } - +func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, targetPath string, auth Auth, source ReleaseSource) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) if err != nil { return "", fmt.Errorf("build artifact download request: %w", err) @@ -496,13 +260,6 @@ func DownloadReleaseAsset( strings.TrimSpace(string(body)), ) } - if resp.ContentLength > 0 && resp.ContentLength > maxDownloadBytes { - return "", fmt.Errorf( - "download release artifact: content length %d exceeds limit %d bytes", - resp.ContentLength, - maxDownloadBytes, - ) - } existingInfo, err := os.Stat(targetPath) if err != nil { @@ -521,14 +278,9 @@ func DownloadReleaseAsset( return "", copyErr } - limited := &io.LimitedReader{R: resp.Body, N: maxDownloadBytes + 1} - written, err := io.Copy(tempFile, limited) - if err != nil { + if _, err := io.Copy(tempFile, resp.Body); err != nil { return cleanup(fmt.Errorf("write downloaded artifact: %w", err)) } - if written > maxDownloadBytes { - return cleanup(fmt.Errorf("write downloaded artifact: size exceeds limit %d bytes", maxDownloadBytes)) - } if err := tempFile.Chmod(existingInfo.Mode().Perm()); err != nil { return cleanup(fmt.Errorf("set executable mode on downloaded artifact: %w", err)) } @@ -540,121 +292,9 @@ func DownloadReleaseAsset( return tempPath, nil } -func VerifyReleaseAssetChecksum( - ctx context.Context, - client *http.Client, - release Release, - releaseURL string, - assetName string, - artifactPath string, - auth Auth, - source ReleaseSource, -) error { - source = normalizeSource(source) - - checksumAssetName := resolveChecksumAssetName(assetName, source.ChecksumAssetName) - checksumURL, err := release.AssetURL(checksumAssetName, releaseURL) - if err != nil { - if source.ChecksumRequired { - return fmt.Errorf("checksum verification: %w", err) - } - return nil - } - - checksumBody, err := downloadAssetBytes(ctx, client, checksumURL, auth, source) - if err != nil { - return fmt.Errorf("checksum verification: %w", err) - } - - expected, err := parseChecksum(string(checksumBody), assetName) - if err != nil { - return fmt.Errorf("checksum verification: %w", err) - } - - actual, err := fileSHA256(artifactPath) - if err != nil { - return fmt.Errorf("checksum verification: %w", err) - } - - if !strings.EqualFold(expected, actual) { - return fmt.Errorf( - "checksum mismatch for asset %q: expected %s, got %s", - assetName, - expected, - actual, - ) - } - - return nil -} - -func VerifyReleaseAssetSignature( - ctx context.Context, - client *http.Client, - release Release, - releaseURL string, - assetName string, - artifactPath string, - auth Auth, - source ReleaseSource, -) error { - source = normalizeSource(source) - - publicKey, hasPublicKey, err := resolveEd25519PublicKey(source.SignaturePublicKey, source.SignaturePublicKeyEnvNames) - if err != nil { - return fmt.Errorf("signature verification: %w", err) - } - if !hasPublicKey { - if source.SignatureRequired { - if len(source.SignaturePublicKeyEnvNames) > 0 { - return fmt.Errorf( - "signature verification: no Ed25519 public key configured (set %s)", - strings.Join(source.SignaturePublicKeyEnvNames, " or "), - ) - } - return errors.New("signature verification: no Ed25519 public key configured") - } - return nil - } - - signatureAssetName := resolveSignatureAssetName(assetName, source.SignatureAssetName) - signatureURL, err := release.AssetURL(signatureAssetName, releaseURL) - if err != nil { - if source.SignatureRequired { - return fmt.Errorf("signature verification: %w", err) - } - return nil - } - - signatureBody, err := downloadAssetBytes(ctx, client, signatureURL, auth, source) - if err != nil { - return fmt.Errorf("signature verification: %w", err) - } - - signature, err := parseEd25519Signature(string(signatureBody), assetName) - if err != nil { - return fmt.Errorf("signature verification: %w", err) - } - - digestHex, err := fileSHA256(artifactPath) - if err != nil { - return fmt.Errorf("signature verification: %w", err) - } - digest, err := hex.DecodeString(digestHex) - if err != nil { - return fmt.Errorf("signature verification: decode local artifact digest: %w", err) - } - - if !ed25519.Verify(publicKey, digest, signature) { - return fmt.Errorf("signature mismatch for asset %q", assetName) - } - - return nil -} - func ReplaceExecutable(downloadPath, targetPath string) error { if runtime.GOOS == "windows" { - return errors.New("self-update is not supported on windows without a custom ReplaceExecutable hook") + return errors.New("self-update is not supported on windows") } if err := os.Rename(downloadPath, targetPath); err != nil { return fmt.Errorf("replace executable %q: %w", targetPath, err) @@ -664,79 +304,9 @@ func ReplaceExecutable(downloadPath, targetPath string) error { func normalizeSource(source ReleaseSource) ReleaseSource { source.Name = strings.TrimSpace(source.Name) - source.Driver = strings.ToLower(strings.TrimSpace(source.Driver)) - source.Repository = strings.Trim(strings.TrimSpace(source.Repository), "/") source.BaseURL = strings.TrimRight(strings.TrimSpace(source.BaseURL), "/") source.LatestReleaseURL = strings.TrimSpace(source.LatestReleaseURL) - source.AssetNameTemplate = strings.TrimSpace(source.AssetNameTemplate) - source.ChecksumAssetName = strings.TrimSpace(source.ChecksumAssetName) - source.SignatureAssetName = strings.TrimSpace(source.SignatureAssetName) - source.SignaturePublicKey = strings.TrimSpace(source.SignaturePublicKey) - source.Token = strings.TrimSpace(source.Token) source.TokenHeader = strings.TrimSpace(source.TokenHeader) - source.TokenPrefix = strings.TrimSpace(source.TokenPrefix) - - envNames := source.TokenEnvNames[:0] - for _, envName := range source.TokenEnvNames { - if trimmed := strings.TrimSpace(envName); trimmed != "" { - envNames = append(envNames, trimmed) - } - } - source.TokenEnvNames = envNames - - publicKeyEnvNames := source.SignaturePublicKeyEnvNames[:0] - for _, envName := range source.SignaturePublicKeyEnvNames { - if trimmed := strings.TrimSpace(envName); trimmed != "" { - publicKeyEnvNames = append(publicKeyEnvNames, trimmed) - } - } - source.SignaturePublicKeyEnvNames = publicKeyEnvNames - - switch source.Driver { - case "gitea": - if source.Name == "" { - source.Name = "Gitea releases" - } - if source.TokenHeader == "" { - source.TokenHeader = "Authorization" - } - if source.TokenPrefix == "" { - source.TokenPrefix = "token " - } - if len(source.TokenEnvNames) == 0 { - source.TokenEnvNames = []string{"GITEA_TOKEN"} - } - case "gitlab": - if source.Name == "" { - source.Name = "GitLab releases" - } - if source.BaseURL == "" { - source.BaseURL = "https://gitlab.com" - } - if source.TokenHeader == "" { - source.TokenHeader = "PRIVATE-TOKEN" - } - if len(source.TokenEnvNames) == 0 { - source.TokenEnvNames = []string{"GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"} - } - case "github": - if source.Name == "" { - source.Name = "GitHub releases" - } - if source.BaseURL == "" { - source.BaseURL = "https://api.github.com" - } - if source.TokenHeader == "" { - source.TokenHeader = "Authorization" - } - if source.TokenPrefix == "" { - source.TokenPrefix = "Bearer " - } - if len(source.TokenEnvNames) == 0 { - source.TokenEnvNames = []string{"GITHUB_TOKEN"} - } - } - return source } @@ -805,338 +375,3 @@ func (a Auth) maybeHint(statusCode int, body []byte, source ReleaseSource) error source.TokenEnvNames[1], ) } - -func parseReleaseLinks(raw json.RawMessage) []ReleaseLink { - if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" { - return nil - } - - parseLinks := func(payload []releaseLinkPayload) []ReleaseLink { - links := make([]ReleaseLink, 0, len(payload)) - for _, item := range payload { - name := strings.TrimSpace(item.Name) - assetURL := firstNonEmpty(item.DirectAssetURL, item.BrowserDownloadURL, item.URL) - if name == "" || strings.TrimSpace(assetURL) == "" { - continue - } - links = append(links, ReleaseLink{ - Name: name, - URL: strings.TrimSpace(assetURL), - }) - } - return links - } - - var asObject releaseAssetsPayload - if err := json.Unmarshal(raw, &asObject); err == nil && len(asObject.Links) > 0 { - return parseLinks(asObject.Links) - } - - var asArray []releaseLinkPayload - if err := json.Unmarshal(raw, &asArray); err == nil && len(asArray) > 0 { - return parseLinks(asArray) - } - - return nil -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - -func withTokenPrefix(token, prefix string) string { - trimmedToken := strings.TrimSpace(token) - if trimmedToken == "" { - return "" - } - - trimmedPrefix := strings.TrimSpace(prefix) - if trimmedPrefix == "" { - return trimmedToken - } - - lowerToken := strings.ToLower(trimmedToken) - lowerPrefix := strings.ToLower(trimmedPrefix) - if strings.HasPrefix(lowerToken, lowerPrefix) { - return trimmedToken - } - - return trimmedPrefix + " " + trimmedToken -} - -func resolveChecksumAssetName(assetName, configured string) string { - value := strings.TrimSpace(configured) - if value == "" { - return assetName + ".sha256" - } - return strings.ReplaceAll(value, "{asset}", assetName) -} - -func resolveSignatureAssetName(assetName, configured string) string { - value := strings.TrimSpace(configured) - if value == "" { - return assetName + ".sig" - } - return strings.ReplaceAll(value, "{asset}", assetName) -} - -func downloadAssetBytes(ctx context.Context, client *http.Client, assetURL string, auth Auth, source ReleaseSource) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) - if err != nil { - return nil, fmt.Errorf("build checksum download request: %w", err) - } - req.Header.Set("User-Agent", "mcp updater") - auth.apply(req) - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("download checksum asset: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - if hint := auth.maybeHint(resp.StatusCode, body, source); hint != nil { - return nil, fmt.Errorf("download checksum asset: %w", hint) - } - return nil, fmt.Errorf( - "download checksum asset: unexpected status %d: %s", - resp.StatusCode, - strings.TrimSpace(string(body)), - ) - } - - content, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) - if err != nil { - return nil, fmt.Errorf("read checksum asset: %w", err) - } - return content, nil -} - -func parseChecksum(content, assetName string) (string, error) { - lines := strings.Split(content, "\n") - fallbackSingle := "" - - for _, raw := range lines { - line := strings.TrimSpace(raw) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - if strings.HasPrefix(strings.ToUpper(line), "SHA256 (") { - openIndex := strings.Index(line, "(") - closeIndex := strings.LastIndex(line, ")") - equalIndex := strings.LastIndex(line, "=") - if openIndex >= 0 && closeIndex > openIndex && equalIndex > closeIndex { - name := strings.TrimSpace(line[openIndex+1 : closeIndex]) - hash := strings.TrimSpace(line[equalIndex+1:]) - if isSHA256Hex(hash) && matchesAssetName(name, assetName) { - return strings.ToLower(hash), nil - } - } - continue - } - - fields := strings.Fields(line) - if len(fields) > 0 && isSHA256Hex(fields[0]) { - if len(fields) == 1 { - if fallbackSingle == "" { - fallbackSingle = strings.ToLower(fields[0]) - } - continue - } - - name := strings.TrimSpace(strings.TrimPrefix(fields[1], "*")) - if matchesAssetName(name, assetName) { - return strings.ToLower(fields[0]), nil - } - continue - } - - colonIndex := strings.Index(line, ":") - if colonIndex > 0 && colonIndex < len(line)-1 { - left := strings.TrimSpace(line[:colonIndex]) - right := strings.TrimSpace(line[colonIndex+1:]) - switch { - case isSHA256Hex(left) && matchesAssetName(right, assetName): - return strings.ToLower(left), nil - case isSHA256Hex(right) && matchesAssetName(left, assetName): - return strings.ToLower(right), nil - } - } - } - - if fallbackSingle != "" { - return fallbackSingle, nil - } - - return "", fmt.Errorf("checksum file does not contain a sha256 for asset %q", assetName) -} - -func parseEd25519Signature(content, assetName string) ([]byte, error) { - lines := strings.Split(content, "\n") - var fallbackSingle []byte - - for _, raw := range lines { - line := strings.TrimSpace(raw) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - fields := strings.Fields(line) - if len(fields) > 0 { - if signature, ok := parseEd25519SignatureToken(fields[0]); ok { - if len(fields) == 1 { - if fallbackSingle == nil { - fallbackSingle = signature - } - continue - } - - name := strings.TrimSpace(strings.TrimPrefix(fields[1], "*")) - if matchesAssetName(name, assetName) { - return signature, nil - } - } - } - - colonIndex := strings.Index(line, ":") - if colonIndex > 0 && colonIndex < len(line)-1 { - left := strings.TrimSpace(line[:colonIndex]) - right := strings.TrimSpace(line[colonIndex+1:]) - - if signature, ok := parseEd25519SignatureToken(left); ok && matchesAssetName(right, assetName) { - return signature, nil - } - if signature, ok := parseEd25519SignatureToken(right); ok && matchesAssetName(left, assetName) { - return signature, nil - } - } - } - - if fallbackSingle != nil { - return fallbackSingle, nil - } - - return nil, fmt.Errorf("signature file does not contain a valid Ed25519 signature for asset %q", assetName) -} - -func parseEd25519SignatureToken(value string) ([]byte, bool) { - decoded, err := decodeBinaryValue(value, ed25519.SignatureSize) - if err != nil { - return nil, false - } - return decoded, true -} - -func resolveEd25519PublicKey(explicit string, envNames []string) (ed25519.PublicKey, bool, error) { - key := strings.TrimSpace(explicit) - if key != "" { - publicKey, err := parseEd25519PublicKey(key) - if err != nil { - return nil, false, fmt.Errorf("parse ed25519 public key: %w", err) - } - return publicKey, true, nil - } - - for _, envName := range envNames { - if value := strings.TrimSpace(os.Getenv(envName)); value != "" { - publicKey, err := parseEd25519PublicKey(value) - if err != nil { - return nil, false, fmt.Errorf("parse ed25519 public key from %s: %w", envName, err) - } - return publicKey, true, nil - } - } - - return nil, false, nil -} - -func parseEd25519PublicKey(value string) (ed25519.PublicKey, error) { - decoded, err := decodeBinaryValue(value, ed25519.PublicKeySize) - if err != nil { - return nil, err - } - return ed25519.PublicKey(decoded), nil -} - -func decodeBinaryValue(value string, expectedLength int) ([]byte, error) { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return nil, errors.New("value must not be empty") - } - - decoders := []func(string) ([]byte, error){ - hex.DecodeString, - base64.StdEncoding.DecodeString, - base64.RawStdEncoding.DecodeString, - base64.URLEncoding.DecodeString, - base64.RawURLEncoding.DecodeString, - } - - lengthMismatch := false - for _, decode := range decoders { - decoded, err := decode(trimmed) - if err != nil { - continue - } - if len(decoded) == expectedLength { - return decoded, nil - } - lengthMismatch = true - } - - if lengthMismatch { - return nil, fmt.Errorf("decoded value has invalid length (expected %d bytes)", expectedLength) - } - return nil, errors.New("value must be hex or base64 encoded") -} - -func matchesAssetName(candidate, assetName string) bool { - name := strings.TrimSpace(strings.TrimPrefix(candidate, "*")) - name = strings.TrimPrefix(name, "./") - if name == assetName { - return true - } - - name = strings.ReplaceAll(name, "\\", "/") - if path.Base(name) == assetName { - return true - } - return false -} - -func isSHA256Hex(value string) bool { - if len(value) != 64 { - return false - } - for _, r := range value { - switch { - case r >= '0' && r <= '9': - case r >= 'a' && r <= 'f': - case r >= 'A' && r <= 'F': - default: - return false - } - } - return true -} - -func fileSHA256(path string) (string, error) { - file, err := os.Open(path) - if err != nil { - return "", fmt.Errorf("open downloaded artifact: %w", err) - } - defer file.Close() - - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { - return "", fmt.Errorf("hash downloaded artifact: %w", err) - } - return hex.EncodeToString(hash.Sum(nil)), nil -} diff --git a/update/update_test.go b/update/update_test.go index 4fa26e5..047ac74 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -3,12 +3,7 @@ package update import ( "bytes" "context" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "encoding/hex" "encoding/json" - "errors" "io" "net/http" "os" @@ -29,20 +24,13 @@ func TestAssetName(t *testing.T) { {name: "darwin amd64", goos: "darwin", goarch: "amd64", want: "graylog-mcp-darwin-amd64"}, {name: "darwin arm64", goos: "darwin", goarch: "arm64", want: "graylog-mcp-darwin-arm64"}, {name: "linux amd64", goos: "linux", goarch: "amd64", want: "graylog-mcp-linux-amd64"}, - {name: "linux arm64", goos: "linux", goarch: "arm64", want: "graylog-mcp-linux-arm64"}, - {name: "windows arm64", goos: "windows", goarch: "arm64", want: "graylog-mcp-windows-arm64.exe"}, - {name: "missing binary", goos: "linux", goarch: "amd64", wantErr: "binary name must not be empty"}, - {name: "missing platform", goos: "", goarch: "amd64", wantErr: "goos and goarch must not be empty"}, + {name: "windows amd64", goos: "windows", goarch: "amd64", want: "graylog-mcp-windows-amd64.exe"}, + {name: "unsupported", goos: "linux", goarch: "arm64", wantErr: "no release artifact"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - binaryName := "graylog-mcp" - if tt.name == "missing binary" { - binaryName = " " - } - - got, err := AssetName(binaryName, tt.goos, tt.goarch) + got, err := AssetName("graylog-mcp", tt.goos, tt.goarch) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %v, want substring %q", err, tt.wantErr) @@ -59,91 +47,6 @@ func TestAssetName(t *testing.T) { } } -func TestAssetNameWithTemplate(t *testing.T) { - got, err := AssetNameWithTemplate("graylog-mcp", "linux", "amd64", "{binary}_{os}_{arch}") - if err != nil { - t.Fatalf("AssetNameWithTemplate: %v", err) - } - if got != "graylog-mcp_linux_amd64" { - t.Fatalf("got %q", got) - } - - _, err = AssetNameWithTemplate("graylog-mcp", "linux", "amd64", "{binary}/{os}/{arch}") - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "must not contain path separators") { - t.Fatalf("error = %v", err) - } -} - -func TestResolveLatestReleaseURL(t *testing.T) { - got, err := ResolveLatestReleaseURL("https://custom/latest", ReleaseSource{ - Driver: "gitea", - Repository: "org/repo", - BaseURL: "https://gitea.example.com", - }) - if err != nil { - t.Fatalf("ResolveLatestReleaseURL explicit: %v", err) - } - if got != "https://custom/latest" { - t.Fatalf("release url = %q", got) - } - - got, err = ResolveLatestReleaseURL("", ReleaseSource{ - LatestReleaseURL: "https://manifest/latest", - }) - if err != nil { - t.Fatalf("ResolveLatestReleaseURL from source: %v", err) - } - if got != "https://manifest/latest" { - t.Fatalf("release url = %q", got) - } - - got, err = ResolveLatestReleaseURL("", ReleaseSource{ - Driver: "gitea", - Repository: "org/repo", - BaseURL: "https://gitea.example.com", - }) - if err != nil { - t.Fatalf("ResolveLatestReleaseURL gitea: %v", err) - } - if got != "https://gitea.example.com/api/v1/repos/org/repo/releases/latest" { - t.Fatalf("release url = %q", got) - } - - got, err = ResolveLatestReleaseURL("", ReleaseSource{ - Driver: "gitlab", - Repository: "group/sub/repo", - BaseURL: "https://gitlab.example.com", - }) - if err != nil { - t.Fatalf("ResolveLatestReleaseURL gitlab: %v", err) - } - if got != "https://gitlab.example.com/api/v4/projects/group%2Fsub%2Frepo/releases/permalink/latest" { - t.Fatalf("release url = %q", got) - } - - got, err = ResolveLatestReleaseURL("", ReleaseSource{ - Driver: "github", - Repository: "org/repo", - }) - if err != nil { - t.Fatalf("ResolveLatestReleaseURL github: %v", err) - } - if got != "https://api.github.com/repos/org/repo/releases/latest" { - t.Fatalf("release url = %q", got) - } - - _, err = ResolveLatestReleaseURL("", ReleaseSource{Driver: "gitea"}) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "requires repository") { - t.Fatalf("error = %v", err) - } -} - func TestResolveUpdateTargetFollowsSymlink(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink behavior differs on windows") @@ -184,37 +87,20 @@ func TestReleaseAssetURLResolvesRelativeLinks(t *testing.T) { } } -func TestReleaseAssetURLErrorIncludesAvailableAssets(t *testing.T) { - release := Release{} - release.Assets.Links = []ReleaseLink{ - {Name: "my-mcp-linux-amd64", URL: "/downloads/my-mcp-linux-amd64"}, - {Name: "my-mcp-darwin-arm64", URL: "/downloads/my-mcp-darwin-arm64"}, - } - - _, err := release.AssetURL("my-mcp-linux-arm64", "https://releases.example.com/latest") - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "available: my-mcp-darwin-arm64, my-mcp-linux-amd64") { - t.Fatalf("error = %v", err) - } -} - func TestResolveAuthPrefersExplicitToken(t *testing.T) { t.Setenv("RELEASE_TOKEN", "env-token") auth := ResolveAuth("explicit-token", ReleaseSource{ - TokenHeader: "Authorization", - TokenPrefix: "Bearer", - TokenEnvNames: []string{ - "RELEASE_TOKEN", - }, + Name: "release endpoint", + BaseURL: "https://releases.example.com", + TokenHeader: "X-Release-Token", + TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, }) - if auth.Header != "Authorization" { - t.Fatalf("header = %q, want Authorization", auth.Header) + if auth.Header != "X-Release-Token" { + t.Fatalf("header = %q, want X-Release-Token", auth.Header) } - if auth.Token != "Bearer explicit-token" { - t.Fatalf("token = %q, want prefixed explicit token", auth.Token) + if auth.Token != "explicit-token" { + t.Fatalf("token = %q, want explicit token", auth.Token) } } @@ -222,11 +108,10 @@ func TestResolveAuthReadsEnvironment(t *testing.T) { t.Setenv("RELEASE_PRIVATE_TOKEN", "env-token") auth := ResolveAuth("", ReleaseSource{ - TokenHeader: "X-Release-Token", - TokenEnvNames: []string{ - "RELEASE_TOKEN", - "RELEASE_PRIVATE_TOKEN", - }, + Name: "release endpoint", + BaseURL: "https://releases.example.com", + TokenHeader: "X-Release-Token", + TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, }) if auth.Header != "X-Release-Token" { t.Fatalf("header = %q, want X-Release-Token", auth.Header) @@ -270,86 +155,6 @@ func TestFetchLatestReleaseAddsConfiguredAuthHeader(t *testing.T) { } } -func TestFetchLatestReleaseSupportsGitHubAssets(t *testing.T) { - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - body := `{ - "tag_name":"v1.2.3", - "assets":[ - {"name":"my-mcp-linux-amd64","browser_download_url":"https://example.com/my-mcp-linux-amd64"} - ] - }` - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(body)), - }, nil - }), - } - - release, err := FetchLatestRelease( - context.Background(), - client, - "https://api.github.com/repos/org/repo/releases/latest", - Auth{}, - ReleaseSource{Name: "GitHub releases"}, - ) - if err != nil { - t.Fatalf("FetchLatestRelease: %v", err) - } - - assetURL, err := release.AssetURL("my-mcp-linux-amd64", "https://api.github.com/repos/org/repo/releases/latest") - if err != nil { - t.Fatalf("AssetURL: %v", err) - } - if assetURL != "https://example.com/my-mcp-linux-amd64" { - t.Fatalf("assetURL = %q", assetURL) - } -} - -func TestFetchLatestReleaseSupportsGitLabDirectAssetURL(t *testing.T) { - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - body := `{ - "tag_name":"v1.2.3", - "assets":{ - "links":[ - { - "name":"my-mcp-linux-amd64", - "url":"https://gitlab.example.com/fallback", - "direct_asset_url":"https://gitlab.example.com/direct/my-mcp-linux-amd64" - } - ] - } - }` - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(body)), - }, nil - }), - } - - release, err := FetchLatestRelease( - context.Background(), - client, - "https://gitlab.example.com/api/v4/projects/org%2Frepo/releases/permalink/latest", - Auth{}, - ReleaseSource{Name: "GitLab releases"}, - ) - if err != nil { - t.Fatalf("FetchLatestRelease: %v", err) - } - - assetURL, err := release.AssetURL("my-mcp-linux-amd64", "https://gitlab.example.com/api/v4/projects/org%2Frepo/releases/permalink/latest") - if err != nil { - t.Fatalf("AssetURL: %v", err) - } - if assetURL != "https://gitlab.example.com/direct/my-mcp-linux-amd64" { - t.Fatalf("assetURL = %q", assetURL) - } -} - func TestFetchLatestReleaseHintsWhenAuthIsMissing(t *testing.T) { client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { @@ -386,74 +191,6 @@ func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) return fn(req) } -func TestDownloadReleaseAssetRejectsArtifactOverLimit(t *testing.T) { - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - if r.URL.String() != "https://releases.example.com/artifact" { - t.Fatalf("unexpected url: %s", r.URL.String()) - } - body := "123456" - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - ContentLength: int64(len(body)), - Body: io.NopCloser(strings.NewReader(body)), - }, nil - }), - } - - tempDir := t.TempDir() - target := filepath.Join(tempDir, "graylog-mcp") - if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { - t.Fatalf("WriteFile target: %v", err) - } - - _, err := DownloadReleaseAsset( - context.Background(), - client, - "https://releases.example.com/artifact", - target, - Auth{}, - ReleaseSource{}, - 5, - ) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "exceeds limit") { - t.Fatalf("error = %v", err) - } -} - -func TestValidateDownloadedArtifactRejectsHTMLDocument(t *testing.T) { - path := filepath.Join(t.TempDir(), "downloaded") - content := "ForbiddenAccess denied" - if err := os.WriteFile(path, []byte(content), 0o755); err != nil { - t.Fatalf("WriteFile: %v", err) - } - - err := validateDownloadedArtifact(path, "graylog-mcp-linux-amd64") - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "looks like an HTML page") { - t.Fatalf("error = %v", err) - } -} - -func TestValidateDownloadedArtifactAcceptsShebangScript(t *testing.T) { - path := filepath.Join(t.TempDir(), "downloaded") - content := "#!/usr/bin/env sh\necho ok\n" - if err := os.WriteFile(path, []byte(content), 0o755); err != nil { - t.Fatalf("WriteFile: %v", err) - } - - err := validateDownloadedArtifact(path, "graylog-mcp-linux-amd64") - if err != nil { - t.Fatalf("validateDownloadedArtifact: %v", err) - } -} - func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("self-replace is not supported on windows") @@ -545,798 +282,6 @@ func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { } } -func TestRunStopsWhenArtifactLooksLikeHTML(t *testing.T) { - assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) - if err != nil { - t.Skipf("unsupported test platform: %v", err) - } - - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - switch r.URL.String() { - case "https://releases.example.com/latest": - release := Release{TagName: "v1.2.3"} - release.Assets.Links = []ReleaseLink{ - {Name: assetName, URL: "https://releases.example.com/artifact"}, - } - - payload, marshalErr := json.Marshal(release) - if marshalErr != nil { - t.Fatalf("Marshal release: %v", marshalErr) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(payload)), - }, nil - case "https://releases.example.com/artifact": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader( - "Access denied", - )), - }, nil - default: - return &http.Response{ - StatusCode: http.StatusNotFound, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("not found")), - }, nil - } - }), - } - - tempDir := t.TempDir() - target := filepath.Join(tempDir, "graylog-mcp") - if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { - t.Fatalf("WriteFile target: %v", err) - } - - replaceCalled := false - err = Run(context.Background(), Options{ - Client: client, - CurrentVersion: "v1.2.2", - ExecutablePath: target, - LatestReleaseURL: "https://releases.example.com/latest", - BinaryName: "graylog-mcp", - ReplaceExecutable: func(downloadPath, targetPath string) error { - replaceCalled = true - return os.WriteFile(targetPath, []byte("unexpected"), 0o755) - }, - }) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "looks like an HTML page") { - t.Fatalf("error = %v", err) - } - if replaceCalled { - t.Fatal("replace hook should not have been called") - } - - got, readErr := os.ReadFile(target) - if readErr != nil { - t.Fatalf("ReadFile target: %v", readErr) - } - if string(got) != "old-binary" { - t.Fatalf("target content = %q, want unchanged binary", string(got)) - } -} - -func TestRunUsesDriverWithoutExplicitLatestReleaseURL(t *testing.T) { - assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) - if err != nil { - t.Skipf("unsupported test platform: %v", err) - } - - latestURL := "https://gitea.example.com/api/v1/repos/org/graylog-mcp/releases/latest" - replaceCalled := false - - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - switch r.URL.String() { - case latestURL: - release := Release{TagName: "v1.2.3"} - release.Assets.Links = []ReleaseLink{ - {Name: assetName, URL: "https://gitea.example.com/downloads/artifact"}, - } - payload, err := json.Marshal(release) - if err != nil { - t.Fatalf("Marshal release: %v", err) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(payload)), - }, nil - case "https://gitea.example.com/downloads/artifact": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("new-binary")), - }, nil - default: - return &http.Response{ - StatusCode: http.StatusNotFound, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("not found")), - }, nil - } - }), - } - - tempDir := t.TempDir() - target := filepath.Join(tempDir, "graylog-mcp") - if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { - t.Fatalf("WriteFile target: %v", err) - } - - err = Run(context.Background(), Options{ - Client: client, - CurrentVersion: "v1.2.2", - ExecutablePath: target, - BinaryName: "graylog-mcp", - ReleaseSource: ReleaseSource{ - Driver: "gitea", - Repository: "org/graylog-mcp", - BaseURL: "https://gitea.example.com", - }, - ReplaceExecutable: func(downloadPath, targetPath string) error { - replaceCalled = true - - data, err := os.ReadFile(downloadPath) - if err != nil { - return err - } - return os.WriteFile(targetPath, data, 0o755) - }, - }) - if err != nil { - t.Fatalf("Run: %v", err) - } - - if !replaceCalled { - t.Fatal("custom replace hook was not called") - } -} - -func TestRunVerifiesChecksumWhenSidecarAvailable(t *testing.T) { - assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) - if err != nil { - t.Skipf("unsupported test platform: %v", err) - } - - const newBinary = "new-binary" - hash := sha256.Sum256([]byte(newBinary)) - checksum := hex.EncodeToString(hash[:]) + " " + assetName + "\n" - - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - switch r.URL.String() { - case "https://releases.example.com/latest": - release := Release{TagName: "v1.2.3"} - release.Assets.Links = []ReleaseLink{ - {Name: assetName, URL: "https://releases.example.com/artifact"}, - {Name: assetName + ".sha256", URL: "https://releases.example.com/artifact.sha256"}, - } - payload, err := json.Marshal(release) - if err != nil { - t.Fatalf("Marshal release: %v", err) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(payload)), - }, nil - case "https://releases.example.com/artifact": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(newBinary)), - }, nil - case "https://releases.example.com/artifact.sha256": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(checksum)), - }, nil - default: - return &http.Response{ - StatusCode: http.StatusNotFound, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("not found")), - }, nil - } - }), - } - - tempDir := t.TempDir() - target := filepath.Join(tempDir, "graylog-mcp") - if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { - t.Fatalf("WriteFile target: %v", err) - } - - err = Run(context.Background(), Options{ - Client: client, - CurrentVersion: "v1.2.2", - ExecutablePath: target, - LatestReleaseURL: "https://releases.example.com/latest", - BinaryName: "graylog-mcp", - ReplaceExecutable: func(downloadPath, targetPath string) error { - data, err := os.ReadFile(downloadPath) - if err != nil { - return err - } - return os.WriteFile(targetPath, data, 0o755) - }, - }) - if err != nil { - t.Fatalf("Run: %v", err) - } - - got, err := os.ReadFile(target) - if err != nil { - t.Fatalf("ReadFile target: %v", err) - } - if string(got) != newBinary { - t.Fatalf("target content = %q, want %q", string(got), newBinary) - } -} - -func TestRunVerifiesEd25519SignatureWhenConfigured(t *testing.T) { - assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) - if err != nil { - t.Skipf("unsupported test platform: %v", err) - } - - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("GenerateKey: %v", err) - } - - const newBinary = "new-binary" - digest := sha256.Sum256([]byte(newBinary)) - signature := ed25519.Sign(privateKey, digest[:]) - signatureBody := hex.EncodeToString(signature) + " " + assetName + "\n" - - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - switch r.URL.String() { - case "https://releases.example.com/latest": - release := Release{TagName: "v1.2.3"} - release.Assets.Links = []ReleaseLink{ - {Name: assetName, URL: "https://releases.example.com/artifact"}, - {Name: assetName + ".sig", URL: "https://releases.example.com/artifact.sig"}, - } - payload, err := json.Marshal(release) - if err != nil { - t.Fatalf("Marshal release: %v", err) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(payload)), - }, nil - case "https://releases.example.com/artifact": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(newBinary)), - }, nil - case "https://releases.example.com/artifact.sig": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(signatureBody)), - }, nil - default: - return &http.Response{ - StatusCode: http.StatusNotFound, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("not found")), - }, nil - } - }), - } - - t.Setenv("RELEASE_PUBKEY", hex.EncodeToString(publicKey)) - - tempDir := t.TempDir() - target := filepath.Join(tempDir, "graylog-mcp") - if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { - t.Fatalf("WriteFile target: %v", err) - } - - err = Run(context.Background(), Options{ - Client: client, - CurrentVersion: "v1.2.2", - ExecutablePath: target, - LatestReleaseURL: "https://releases.example.com/latest", - BinaryName: "graylog-mcp", - ReleaseSource: ReleaseSource{ - SignatureRequired: true, - SignaturePublicKeyEnvNames: []string{"RELEASE_PUBKEY"}, - }, - ReplaceExecutable: func(downloadPath, targetPath string) error { - data, readErr := os.ReadFile(downloadPath) - if readErr != nil { - return readErr - } - return os.WriteFile(targetPath, data, 0o755) - }, - }) - if err != nil { - t.Fatalf("Run: %v", err) - } -} - -func TestRunFailsOnEd25519SignatureMismatch(t *testing.T) { - assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) - if err != nil { - t.Skipf("unsupported test platform: %v", err) - } - - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("GenerateKey: %v", err) - } - - signature := ed25519.Sign(privateKey, []byte("wrong-digest")) - signatureBody := hex.EncodeToString(signature) + " " + assetName + "\n" - - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - switch r.URL.String() { - case "https://releases.example.com/latest": - release := Release{TagName: "v1.2.3"} - release.Assets.Links = []ReleaseLink{ - {Name: assetName, URL: "https://releases.example.com/artifact"}, - {Name: assetName + ".sig", URL: "https://releases.example.com/artifact.sig"}, - } - payload, err := json.Marshal(release) - if err != nil { - t.Fatalf("Marshal release: %v", err) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(payload)), - }, nil - case "https://releases.example.com/artifact": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("new-binary")), - }, nil - case "https://releases.example.com/artifact.sig": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(signatureBody)), - }, nil - default: - return &http.Response{ - StatusCode: http.StatusNotFound, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("not found")), - }, nil - } - }), - } - - t.Setenv("RELEASE_PUBKEY", hex.EncodeToString(publicKey)) - - tempDir := t.TempDir() - target := filepath.Join(tempDir, "graylog-mcp") - if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { - t.Fatalf("WriteFile target: %v", err) - } - - err = Run(context.Background(), Options{ - Client: client, - CurrentVersion: "v1.2.2", - ExecutablePath: target, - LatestReleaseURL: "https://releases.example.com/latest", - BinaryName: "graylog-mcp", - ReleaseSource: ReleaseSource{ - SignatureRequired: true, - SignaturePublicKeyEnvNames: []string{"RELEASE_PUBKEY"}, - }, - ReplaceExecutable: func(downloadPath, targetPath string) error { - data, readErr := os.ReadFile(downloadPath) - if readErr != nil { - return readErr - } - return os.WriteFile(targetPath, data, 0o755) - }, - }) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "signature mismatch") { - t.Fatalf("error = %v", err) - } -} - -func TestRunFailsWhenSignatureRequiredAndPublicKeyMissing(t *testing.T) { - assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) - if err != nil { - t.Skipf("unsupported test platform: %v", err) - } - - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - switch r.URL.String() { - case "https://releases.example.com/latest": - release := Release{TagName: "v1.2.3"} - release.Assets.Links = []ReleaseLink{ - {Name: assetName, URL: "https://releases.example.com/artifact"}, - } - payload, err := json.Marshal(release) - if err != nil { - t.Fatalf("Marshal release: %v", err) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(payload)), - }, nil - case "https://releases.example.com/artifact": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("new-binary")), - }, nil - default: - return &http.Response{ - StatusCode: http.StatusNotFound, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("not found")), - }, nil - } - }), - } - - tempDir := t.TempDir() - target := filepath.Join(tempDir, "graylog-mcp") - if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { - t.Fatalf("WriteFile target: %v", err) - } - - err = Run(context.Background(), Options{ - Client: client, - CurrentVersion: "v1.2.2", - ExecutablePath: target, - LatestReleaseURL: "https://releases.example.com/latest", - BinaryName: "graylog-mcp", - ReleaseSource: ReleaseSource{ - SignatureRequired: true, - }, - ReplaceExecutable: func(downloadPath, targetPath string) error { - data, readErr := os.ReadFile(downloadPath) - if readErr != nil { - return readErr - } - return os.WriteFile(targetPath, data, 0o755) - }, - }) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(strings.ToLower(err.Error()), "public key") { - t.Fatalf("error = %v", err) - } -} - -func TestRunFailsOnChecksumMismatch(t *testing.T) { - assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) - if err != nil { - t.Skipf("unsupported test platform: %v", err) - } - - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - switch r.URL.String() { - case "https://releases.example.com/latest": - release := Release{TagName: "v1.2.3"} - release.Assets.Links = []ReleaseLink{ - {Name: assetName, URL: "https://releases.example.com/artifact"}, - {Name: assetName + ".sha256", URL: "https://releases.example.com/artifact.sha256"}, - } - payload, err := json.Marshal(release) - if err != nil { - t.Fatalf("Marshal release: %v", err) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(payload)), - }, nil - case "https://releases.example.com/artifact": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("new-binary")), - }, nil - case "https://releases.example.com/artifact.sha256": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa " + assetName)), - }, nil - default: - return &http.Response{ - StatusCode: http.StatusNotFound, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("not found")), - }, nil - } - }), - } - - tempDir := t.TempDir() - target := filepath.Join(tempDir, "graylog-mcp") - if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { - t.Fatalf("WriteFile target: %v", err) - } - - err = Run(context.Background(), Options{ - Client: client, - CurrentVersion: "v1.2.2", - ExecutablePath: target, - LatestReleaseURL: "https://releases.example.com/latest", - BinaryName: "graylog-mcp", - ReplaceExecutable: func(downloadPath, targetPath string) error { - data, readErr := os.ReadFile(downloadPath) - if readErr != nil { - return readErr - } - return os.WriteFile(targetPath, data, 0o755) - }, - }) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "checksum mismatch") { - t.Fatalf("error = %v", err) - } -} - -func TestRunFailsWhenChecksumRequiredAndMissing(t *testing.T) { - assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) - if err != nil { - t.Skipf("unsupported test platform: %v", err) - } - - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - switch r.URL.String() { - case "https://releases.example.com/latest": - release := Release{TagName: "v1.2.3"} - release.Assets.Links = []ReleaseLink{ - {Name: assetName, URL: "https://releases.example.com/artifact"}, - } - payload, err := json.Marshal(release) - if err != nil { - t.Fatalf("Marshal release: %v", err) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(payload)), - }, nil - case "https://releases.example.com/artifact": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("new-binary")), - }, nil - default: - return &http.Response{ - StatusCode: http.StatusNotFound, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("not found")), - }, nil - } - }), - } - - tempDir := t.TempDir() - target := filepath.Join(tempDir, "graylog-mcp") - if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { - t.Fatalf("WriteFile target: %v", err) - } - - err = Run(context.Background(), Options{ - Client: client, - CurrentVersion: "v1.2.2", - ExecutablePath: target, - LatestReleaseURL: "https://releases.example.com/latest", - BinaryName: "graylog-mcp", - ReleaseSource: ReleaseSource{ - ChecksumRequired: true, - }, - ReplaceExecutable: func(downloadPath, targetPath string) error { - data, readErr := os.ReadFile(downloadPath) - if readErr != nil { - return readErr - } - return os.WriteFile(targetPath, data, 0o755) - }, - }) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "checksum verification") { - t.Fatalf("error = %v", err) - } -} - -func TestRunInvokesValidationHook(t *testing.T) { - assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) - if err != nil { - t.Skipf("unsupported test platform: %v", err) - } - - validateCalled := false - replaceCalled := false - - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - switch r.URL.String() { - case "https://releases.example.com/latest": - release := Release{TagName: "v1.2.3"} - release.Assets.Links = []ReleaseLink{ - {Name: assetName, URL: "https://releases.example.com/artifact"}, - } - payload, err := json.Marshal(release) - if err != nil { - t.Fatalf("Marshal release: %v", err) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(payload)), - }, nil - case "https://releases.example.com/artifact": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("new-binary")), - }, nil - default: - return &http.Response{ - StatusCode: http.StatusNotFound, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("not found")), - }, nil - } - }), - } - - tempDir := t.TempDir() - target := filepath.Join(tempDir, "graylog-mcp") - if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { - t.Fatalf("WriteFile target: %v", err) - } - - err = Run(context.Background(), Options{ - Client: client, - CurrentVersion: "v1.2.2", - ExecutablePath: target, - LatestReleaseURL: "https://releases.example.com/latest", - BinaryName: "graylog-mcp", - ValidateDownloaded: func(_ context.Context, input ValidationInput) error { - validateCalled = true - if input.AssetName != assetName { - t.Fatalf("asset name = %q", input.AssetName) - } - data, readErr := os.ReadFile(input.DownloadPath) - if readErr != nil { - return readErr - } - if string(data) != "new-binary" { - t.Fatalf("downloaded content = %q", string(data)) - } - return nil - }, - ReplaceExecutable: func(downloadPath, targetPath string) error { - replaceCalled = true - data, readErr := os.ReadFile(downloadPath) - if readErr != nil { - return readErr - } - return os.WriteFile(targetPath, data, 0o755) - }, - }) - if err != nil { - t.Fatalf("Run: %v", err) - } - - if !validateCalled { - t.Fatal("validation hook was not called") - } - if !replaceCalled { - t.Fatal("replace hook was not called") - } -} - -func TestRunStopsWhenValidationHookFails(t *testing.T) { - assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) - if err != nil { - t.Skipf("unsupported test platform: %v", err) - } - - client := &http.Client{ - Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - switch r.URL.String() { - case "https://releases.example.com/latest": - release := Release{TagName: "v1.2.3"} - release.Assets.Links = []ReleaseLink{ - {Name: assetName, URL: "https://releases.example.com/artifact"}, - } - payload, err := json.Marshal(release) - if err != nil { - t.Fatalf("Marshal release: %v", err) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(payload)), - }, nil - case "https://releases.example.com/artifact": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("new-binary")), - }, nil - default: - return &http.Response{ - StatusCode: http.StatusNotFound, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("not found")), - }, nil - } - }), - } - - tempDir := t.TempDir() - target := filepath.Join(tempDir, "graylog-mcp") - if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { - t.Fatalf("WriteFile target: %v", err) - } - - replaceCalled := false - err = Run(context.Background(), Options{ - Client: client, - CurrentVersion: "v1.2.2", - ExecutablePath: target, - LatestReleaseURL: "https://releases.example.com/latest", - BinaryName: "graylog-mcp", - ValidateDownloaded: func(context.Context, ValidationInput) error { - return errors.New("signature invalid") - }, - ReplaceExecutable: func(downloadPath, targetPath string) error { - replaceCalled = true - data, readErr := os.ReadFile(downloadPath) - if readErr != nil { - return readErr - } - return os.WriteFile(targetPath, data, 0o755) - }, - }) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "validate downloaded artifact") { - t.Fatalf("error = %v", err) - } - if replaceCalled { - t.Fatal("replace hook should not have been called") - } -} - func TestRunSkipsWhenAlreadyOnLatestRelease(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { @@ -1419,7 +364,7 @@ func TestRunSkipsWhenAlreadyOnLatestRelease(t *testing.T) { } } -func TestRunRequiresLatestReleaseURLOrDriver(t *testing.T) { +func TestRunRequiresLatestReleaseURL(t *testing.T) { target := filepath.Join(t.TempDir(), "graylog-mcp") if err := os.WriteFile(target, []byte("current-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err)