Compare commits
32 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0de2a2b351 | |||
| 72fb48f59e | |||
| 23fff88ccb | |||
| 235727106d | |||
| 1e0bdfc42d | |||
| 51dda82cd0 | |||
| fbaea341c7 | |||
| ebd3a64522 | |||
| 801d7fbb95 | |||
| fe9e70b61a | |||
| be2b7e631b | |||
| be33b467a6 | |||
| a87db60345 | |||
| 9b90332b7c | |||
| 9c6f8b70d4 | |||
| b6276776fd | |||
| 678b966425 | |||
| 226d636ca2 | |||
| a19b35cbea | |||
| 21808ddce0 | |||
| ec3774138e | |||
| 647a03a10c | |||
| 7315f2658c | |||
| 46c94a1a12 | |||
| ba42be8d77 | |||
|
|
fcee5a0a36 | ||
|
|
8c88084181 | ||
|
|
b32b6c8a55 | ||
|
|
3ba3475753 | ||
|
|
9b0bd7e175 | ||
|
|
7998e049cb | ||
|
|
88818641e4 |
19 changed files with 1823 additions and 443 deletions
|
|
@ -12,6 +12,7 @@ jobs:
|
|||
env:
|
||||
BINARY_NAME: email-mcp
|
||||
BUILD_PATH: build/email-mcp-linux-amd64
|
||||
CHECKSUM_PATH: build/email-mcp-linux-amd64.sha256
|
||||
MANIFEST_PATH: mcp.toml
|
||||
|
||||
steps:
|
||||
|
|
@ -28,6 +29,14 @@ jobs:
|
|||
- name: Build linux amd64 binary
|
||||
run: make build GOOS=linux GOARCH=amd64
|
||||
|
||||
- name: Generate binary checksum
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
asset_name="$(basename "${BUILD_PATH}")"
|
||||
checksum_value="$(sha256sum "${BUILD_PATH}" | cut -d' ' -f1)"
|
||||
printf '%s %s\n' "${checksum_value}" "${asset_name}" > "${CHECKSUM_PATH}"
|
||||
|
||||
- name: Generate release notes
|
||||
id: release_notes
|
||||
env:
|
||||
|
|
@ -165,3 +174,30 @@ jobs:
|
|||
cat asset.json >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload checksum asset
|
||||
env:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
API_URL: ${{ github.api_url }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
owner="${REPOSITORY%%/*}"
|
||||
repo="${REPOSITORY#*/}"
|
||||
asset_name="$(basename "${CHECKSUM_PATH}")"
|
||||
upload_url="${API_URL}/repos/${owner}/${repo}/releases/${RELEASE_ID}/assets?name=${asset_name}"
|
||||
|
||||
http_code="$(curl -sS -o asset.json -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"${CHECKSUM_PATH}" \
|
||||
"${upload_url}")"
|
||||
|
||||
if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
|
||||
echo "asset upload failed with status ${http_code}" >&2
|
||||
cat asset.json >&2
|
||||
exit 1
|
||||
fi
|
||||
10
Makefile
10
Makefile
|
|
@ -14,7 +14,7 @@ endif
|
|||
|
||||
OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(EXT)
|
||||
|
||||
.PHONY: build test
|
||||
.PHONY: build test generate generate-check
|
||||
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR) $(GOCACHE)
|
||||
|
|
@ -23,3 +23,11 @@ build:
|
|||
test:
|
||||
@mkdir -p $(GOCACHE)
|
||||
GOCACHE=$(GOCACHE) go test ./...
|
||||
|
||||
generate:
|
||||
@mkdir -p $(GOCACHE)
|
||||
GOCACHE=$(GOCACHE) go run forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework generate
|
||||
|
||||
generate-check:
|
||||
@mkdir -p $(GOCACHE)
|
||||
GOCACHE=$(GOCACHE) go run forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework generate --check
|
||||
|
|
|
|||
59
README.md
59
README.md
|
|
@ -6,8 +6,9 @@ Le binaire s’appuie maintenant sur [`mcp-framework`](../mcp-framework) pour :
|
|||
|
||||
- la gestion de profils CLI
|
||||
- le stockage JSON de configuration dans `os.UserConfigDir()`
|
||||
- le stockage du mot de passe dans le wallet natif de l’OS
|
||||
- le stockage du mot de passe dans Bitwarden via `bw`
|
||||
- le manifeste `mcp.toml`
|
||||
- les helpers Go générés depuis `mcp.toml` (`mcpgen/`)
|
||||
- l’auto-update via `email-mcp update`
|
||||
|
||||
## Commandes
|
||||
|
|
@ -15,8 +16,9 @@ Le binaire s’appuie maintenant sur [`mcp-framework`](../mcp-framework) pour :
|
|||
- `email-mcp setup` : configure (ou met à jour) un profil IMAP
|
||||
- `email-mcp config show` : affiche la configuration IMAP résolue et la provenance
|
||||
- `email-mcp config test` : lance les checks de configuration/connectivité (équivalent de `doctor`)
|
||||
- `email-mcp config delete` : supprime un profil local et son mot de passe stocké
|
||||
- `email-mcp mcp` : lance le serveur MCP sur `stdin/stdout`
|
||||
- `email-mcp doctor` : diagnostique la configuration locale, le wallet, le manifeste et l’accès IMAP
|
||||
- `email-mcp doctor` : diagnostique la configuration locale, Bitwarden, le manifeste et l’accès IMAP
|
||||
- `email-mcp update` : met à jour le binaire courant depuis la dernière release
|
||||
- `email-mcp version` : affiche la version du binaire
|
||||
|
||||
|
|
@ -33,20 +35,21 @@ La commande `email-mcp help` (ou `-h` / `--help`) affiche l’aide globale.
|
|||
La configuration est séparée en deux parties :
|
||||
|
||||
- `host` et `username` sont stockés dans `config.json`
|
||||
- `password` est stocké dans le wallet système
|
||||
- `password` est stocké dans Bitwarden via le CLI `bw`
|
||||
|
||||
Le profil actif est résolu dans cet ordre :
|
||||
|
||||
1. `--profile`
|
||||
2. `EMAIL_MCP_PROFILE`
|
||||
3. `current_profile` dans `config.json`
|
||||
4. `default`
|
||||
4. `[profiles].default` dans `mcp.toml`
|
||||
5. `default`
|
||||
|
||||
Les credentials IMAP sont résolus ensuite via le résolveur multi-sources du framework (RC3) :
|
||||
Les credentials IMAP sont résolus ensuite via les champs `[[config.fields]]` du manifeste et les helpers générés par le framework :
|
||||
|
||||
1. `host` : `EMAIL_MCP_HOST` puis `config.json`
|
||||
2. `username` : `EMAIL_MCP_USERNAME` puis `config.json`
|
||||
3. `password` : `EMAIL_MCP_PASSWORD` puis secret wallet `imap-password/<profile>`
|
||||
3. `password` : `EMAIL_MCP_PASSWORD` puis secret Bitwarden `imap-password/<profile>`
|
||||
|
||||
### Configurer un profil
|
||||
|
||||
|
|
@ -66,7 +69,9 @@ Le binaire demande ensuite :
|
|||
2. le nom d’utilisateur
|
||||
3. le mot de passe
|
||||
|
||||
Si un mot de passe existe déjà dans le wallet, laisser le champ vide le conserve.
|
||||
Si un mot de passe existe déjà dans Bitwarden, laisser le champ vide le conserve.
|
||||
|
||||
Le backend de secrets déclaré dans `mcp.toml` est `bitwarden-cli`. Le CLI `bw` doit être installé, connecté et déverrouillé avec `BW_SESSION` disponible dans l’environnement. `EMAIL_MCP_PASSWORD` reste accepté pour fournir le mot de passe sans lire Bitwarden.
|
||||
|
||||
### Lancer le serveur MCP
|
||||
|
||||
|
|
@ -108,13 +113,20 @@ credentials not configured; run `email-mcp setup`
|
|||
./email-mcp update
|
||||
```
|
||||
|
||||
Le manifeste de ce repo pointe vers l’endpoint Gitea :
|
||||
Le manifeste de ce repo utilise le driver Gitea du framework :
|
||||
|
||||
```toml
|
||||
binary_name = "email-mcp"
|
||||
|
||||
[update]
|
||||
source_name = "email-mcp releases"
|
||||
driver = "gitea"
|
||||
repository = "AI/email-mcp"
|
||||
base_url = "https://gitea.lclr.dev"
|
||||
latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/latest"
|
||||
asset_name_template = "{binary}-{os}-{arch}{ext}"
|
||||
checksum_asset_name = "{asset}.sha256"
|
||||
checksum_required = true
|
||||
token_env_names = ["GITEA_TOKEN"]
|
||||
```
|
||||
|
||||
## Diagnostic
|
||||
|
|
@ -123,7 +135,7 @@ latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/
|
|||
|
||||
- la lisibilité du fichier de configuration
|
||||
- le profil IMAP résolu
|
||||
- la disponibilité du wallet système
|
||||
- la disponibilité du backend Bitwarden
|
||||
- la présence du mot de passe stocké
|
||||
- la validité du manifeste `mcp.toml`
|
||||
- la connectivité IMAP avec les credentials résolus
|
||||
|
|
@ -135,8 +147,27 @@ latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/
|
|||
|
||||
La commande retourne un code de sortie non nul si au moins un check échoue.
|
||||
|
||||
Pour l’update, la validation du manifeste accepte :
|
||||
|
||||
- soit `update.latest_release_url`
|
||||
- soit un couple driver/référentiel (`update.driver`, `update.repository`) avec les champs requis (ex. `update.base_url` pour Gitea)
|
||||
|
||||
## Installation
|
||||
|
||||
### Installateur interactif
|
||||
|
||||
Le repo inclut un assistant interactif `install.sh` :
|
||||
|
||||
```sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
Tu peux aussi l’exécuter directement depuis la branche `main` :
|
||||
|
||||
```sh
|
||||
curl -fsSL https://gitea.lclr.dev/AI/email-mcp/raw/branch/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Claude Code CLI
|
||||
|
||||
Ajoute le serveur MCP en pointant vers le binaire et la sous-commande `mcp` :
|
||||
|
|
@ -169,6 +200,7 @@ Une release est générée automatiquement quand tu pousses un tag `v*` sur le r
|
|||
Les assets publiés sont :
|
||||
|
||||
- `build/email-mcp-linux-amd64`
|
||||
- `build/email-mcp-linux-amd64.sha256`
|
||||
- `mcp.toml`
|
||||
|
||||
## Compiler depuis les sources
|
||||
|
|
@ -190,3 +222,10 @@ Pour lancer les tests :
|
|||
```sh
|
||||
make test
|
||||
```
|
||||
|
||||
Pour régénérer la glue framework après une modification de `mcp.toml` :
|
||||
|
||||
```sh
|
||||
make generate
|
||||
make generate-check
|
||||
```
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -3,7 +3,7 @@ module email-mcp
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
gitea.lclr.dev/AI/mcp-framework v1.2.1
|
||||
forge.lclr.dev/AI/mcp-framework v1.13.0
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-message v0.18.2
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
|
|
|
|||
10
go.sum
10
go.sum
|
|
@ -1,5 +1,11 @@
|
|||
gitea.lclr.dev/AI/mcp-framework v1.2.1 h1:4sM47gKeR6N4tqTr92ExCUZjxbCpnGPuZkfbigSVgPM=
|
||||
gitea.lclr.dev/AI/mcp-framework v1.2.1/go.mod h1:kUVMrL3/UBYgjOsW7sJCs3V0pO0qoJJMpIpueoTsoA4=
|
||||
forge.lclr.dev/AI/mcp-framework v1.9.0 h1:8i2CHQlQo/mRG1BE2UArHptAa/HC7AOhZBIqz8md8Vk=
|
||||
forge.lclr.dev/AI/mcp-framework v1.9.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
|
||||
forge.lclr.dev/AI/mcp-framework v1.10.0 h1:RrTy7K/hSruaVS9Z/oaRpkLs2U5WGs4H3tox7PiErak=
|
||||
forge.lclr.dev/AI/mcp-framework v1.10.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
|
||||
forge.lclr.dev/AI/mcp-framework v1.12.0 h1:pu1cfWcL62BF+f7DBe4IbkigHLcK6YOJ3vEBz1495AY=
|
||||
forge.lclr.dev/AI/mcp-framework v1.12.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
|
||||
forge.lclr.dev/AI/mcp-framework v1.13.0 h1:YfC/AqdzTHGRgtZxMl7CfDN+duFezyQ4nkX9uTD+HX0=
|
||||
forge.lclr.dev/AI/mcp-framework v1.13.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
|
||||
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
|
||||
|
|
|
|||
625
install.sh
Executable file
625
install.sh
Executable file
|
|
@ -0,0 +1,625 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BINARY_NAME="email-mcp"
|
||||
DEFAULT_PROFILE="default"
|
||||
PROFILE_ENV="EMAIL_MCP_PROFILE"
|
||||
RELEASE_BASE_URL="https://forge.lclr.dev"
|
||||
RELEASE_REPOSITORY="AI/email-mcp"
|
||||
INSTALLED_BINARY_PATH=""
|
||||
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'
|
||||
}
|
||||
|
||||
go_bin_dir() {
|
||||
local gobin
|
||||
gobin="$(go env GOBIN 2>/dev/null || true)"
|
||||
if [ -n "$gobin" ]; then
|
||||
printf "%s\n" "$gobin"
|
||||
return
|
||||
fi
|
||||
|
||||
go env GOPATH 2>/dev/null | awk '{print $1 "/bin"}'
|
||||
}
|
||||
|
||||
resolve_binary_path() {
|
||||
if [ -n "$INSTALLED_BINARY_PATH" ] && [ -x "$INSTALLED_BINARY_PATH" ]; then
|
||||
printf "%s\n" "$INSTALLED_BINARY_PATH"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
|
||||
command -v "$BINARY_NAME"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
local bin_dir
|
||||
bin_dir="$(go_bin_dir)"
|
||||
if [ -n "$bin_dir" ] && [ -x "$bin_dir/$BINARY_NAME" ]; then
|
||||
printf "%s\n" "$bin_dir/$BINARY_NAME"
|
||||
return
|
||||
fi
|
||||
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
|
||||
}
|
||||
|
||||
detect_os() {
|
||||
case "$(uname -s | tr '[:upper:]' '[:lower:]')" in
|
||||
linux) printf "linux" ;;
|
||||
darwin) printf "darwin" ;;
|
||||
*)
|
||||
ui_error "OS non supporte: $(uname -s)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) printf "amd64" ;;
|
||||
aarch64|arm64) printf "arm64" ;;
|
||||
*)
|
||||
ui_error "Architecture non supportee: $(uname -m)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
release_api_url() {
|
||||
printf "%s/api/v1/repos/%s/releases/latest\n" "${RELEASE_BASE_URL%/}" "$RELEASE_REPOSITORY"
|
||||
}
|
||||
|
||||
extract_asset_url() {
|
||||
local json="$1"
|
||||
local asset_name="$2"
|
||||
|
||||
printf "%s\n" "$json" \
|
||||
| grep -o '"browser_download_url":"[^"]*"' \
|
||||
| sed 's/"browser_download_url":"//;s/"$//' \
|
||||
| sed 's#\\/#/#g' \
|
||||
| grep "/${asset_name}$" \
|
||||
| head -n 1
|
||||
}
|
||||
|
||||
verify_checksum() {
|
||||
local binary_path="$1"
|
||||
local checksum_path="$2"
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
( cd "$(dirname "$binary_path")" && sha256sum -c "$(basename "$checksum_path")" )
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
local expected
|
||||
local actual
|
||||
expected="$(awk '{print $1}' "$checksum_path")"
|
||||
actual="$(shasum -a 256 "$binary_path" | awk '{print $1}')"
|
||||
if [ "$expected" != "$actual" ]; then
|
||||
ui_error "Checksum invalide pour $binary_path"
|
||||
exit 1
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
ui_warn "Aucun utilitaire checksum trouve (sha256sum/shasum), verification ignoree."
|
||||
}
|
||||
|
||||
download_latest_release_binary() {
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
ui_error "curl est requis pour telecharger la release."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local os_name
|
||||
local arch_name
|
||||
local ext=""
|
||||
os_name="$(detect_os)"
|
||||
arch_name="$(detect_arch)"
|
||||
if [ "$os_name" = "windows" ]; then
|
||||
ext=".exe"
|
||||
fi
|
||||
|
||||
local asset_name
|
||||
local checksum_name
|
||||
asset_name="${BINARY_NAME}-${os_name}-${arch_name}${ext}"
|
||||
checksum_name="${asset_name}.sha256"
|
||||
|
||||
ui_info "Recuperation de la derniere release..."
|
||||
local release_json
|
||||
release_json="$(curl -fsSL "$(release_api_url)")"
|
||||
|
||||
local asset_url
|
||||
asset_url="$(extract_asset_url "$release_json" "$asset_name")"
|
||||
if [ -z "$asset_url" ]; then
|
||||
ui_error "Asset introuvable dans la derniere release: $asset_name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local checksum_url
|
||||
checksum_url="$(extract_asset_url "$release_json" "$checksum_name" || true)"
|
||||
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'if [ -n "${tmp_dir:-}" ] && [ -d "${tmp_dir:-}" ]; then rm -rf "${tmp_dir}"; fi' EXIT INT TERM
|
||||
|
||||
curl -fsSL "$asset_url" -o "$tmp_dir/$asset_name"
|
||||
chmod +x "$tmp_dir/$asset_name"
|
||||
|
||||
if [ -n "$checksum_url" ]; then
|
||||
curl -fsSL "$checksum_url" -o "$tmp_dir/$checksum_name"
|
||||
verify_checksum "$tmp_dir/$asset_name" "$tmp_dir/$checksum_name"
|
||||
fi
|
||||
|
||||
local target_dir
|
||||
target_dir="$(prompt "Repertoire d'installation" "$HOME/.local/bin")"
|
||||
mkdir -p "$target_dir"
|
||||
install -m 0755 "$tmp_dir/$asset_name" "$target_dir/$BINARY_NAME"
|
||||
INSTALLED_BINARY_PATH="$target_dir/$BINARY_NAME"
|
||||
|
||||
ui_success "Binaire installe: $INSTALLED_BINARY_PATH"
|
||||
ui_info "Ajoute ce dossier au PATH si necessaire."
|
||||
}
|
||||
|
||||
install_binary() {
|
||||
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
|
||||
INSTALLED_BINARY_PATH="$(command -v "$BINARY_NAME")"
|
||||
ui_success "Binaire detecte: $INSTALLED_BINARY_PATH"
|
||||
local reinstall
|
||||
reinstall="$(prompt "Forcer une reinstall depuis la derniere release ? (y/N)" "N")"
|
||||
case "$reinstall" in
|
||||
y|Y|yes|YES)
|
||||
download_latest_release_binary
|
||||
return
|
||||
;;
|
||||
*)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
download_latest_release_binary
|
||||
}
|
||||
|
||||
run_setup_wizard() {
|
||||
install_binary
|
||||
|
||||
local profile
|
||||
profile="$(prompt "Profil a configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")"
|
||||
local binary_path
|
||||
binary_path="$(resolve_binary_path)"
|
||||
|
||||
if [ ! -x "$binary_path" ]; then
|
||||
ui_error "Binaire introuvable/executable: $binary_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ui_info "Déverrouillage Bitwarden avant le setup..."
|
||||
if [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
|
||||
env "${PROFILE_ENV}=${profile}" "$binary_path" login < /dev/tty > /dev/tty
|
||||
else
|
||||
env "${PROFILE_ENV}=${profile}" "$binary_path" login
|
||||
fi
|
||||
|
||||
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 <<JSON
|
||||
{
|
||||
"mcpServers": {
|
||||
"${SERVER_NAME}": {
|
||||
"command": "${COMMAND_PATH}",
|
||||
"args": ["mcp"],
|
||||
"env": {
|
||||
"${PROFILE_ENV}": "${PROFILE_VALUE}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
}
|
||||
|
||||
print_header() {
|
||||
ui_line
|
||||
printf "%bMCP Install Wizard%b for %b%s%b\n" "$C_BOLD$C_MAGENTA" "$C_RESET" "$C_BOLD" "$BINARY_NAME" "$C_RESET" >&2
|
||||
printf "%bRelease repo:%b %s\n" "$C_DIM" "$C_RESET" "$RELEASE_REPOSITORY" >&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 "$@"
|
||||
|
|
@ -8,26 +8,28 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
frameworkbootstrap "gitea.lclr.dev/AI/mcp-framework/bootstrap"
|
||||
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
||||
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
|
||||
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update"
|
||||
"email-mcp/mcpgen"
|
||||
frameworkbootstrap "forge.lclr.dev/AI/mcp-framework/bootstrap"
|
||||
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
|
||||
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
frameworkupdate "forge.lclr.dev/AI/mcp-framework/update"
|
||||
|
||||
"email-mcp/internal/mcpserver"
|
||||
"email-mcp/internal/secretstore"
|
||||
)
|
||||
|
||||
const (
|
||||
binaryName = "email-mcp"
|
||||
binaryName = mcpgen.BinaryName
|
||||
defaultProfileEnv = "EMAIL_MCP_PROFILE"
|
||||
hostEnv = "EMAIL_MCP_HOST"
|
||||
usernameEnv = "EMAIL_MCP_USERNAME"
|
||||
passwordEnv = "EMAIL_MCP_PASSWORD"
|
||||
binaryDescription = "Local MCP server to read an IMAP mailbox."
|
||||
fallbackProfile = "default"
|
||||
)
|
||||
|
||||
type MCPRunner interface {
|
||||
|
|
@ -43,11 +45,7 @@ type profileConfigStore interface {
|
|||
SaveDefault(frameworkconfig.FileConfig[ProfileConfig]) (string, error)
|
||||
}
|
||||
|
||||
type secretStore interface {
|
||||
SetSecret(name, label, secret string) error
|
||||
GetSecret(name string) (string, error)
|
||||
DeleteSecret(name string) error
|
||||
}
|
||||
type secretStore = frameworksecretstore.Store
|
||||
|
||||
type manifestLoader func(startDir string) (frameworkmanifest.File, string, error)
|
||||
type executableResolver func() (string, error)
|
||||
|
|
@ -117,26 +115,20 @@ func NewAppWithDependencies(
|
|||
}
|
||||
|
||||
func (a *App) Run(args []string) error {
|
||||
if isDoctorHelpCommand(args) {
|
||||
return a.printDoctorHelp()
|
||||
if args == nil {
|
||||
args = []string{}
|
||||
}
|
||||
|
||||
if len(args) > 0 && strings.TrimSpace(args[0]) == "doctor" {
|
||||
return a.runDoctor(context.Background(), args[1:])
|
||||
}
|
||||
|
||||
if isGlobalHelpCommand(args) {
|
||||
return a.printGlobalHelp()
|
||||
}
|
||||
|
||||
return a.runBootstrap(context.Background(), args)
|
||||
}
|
||||
|
||||
func (a *App) runBootstrap(ctx context.Context, args []string) error {
|
||||
metadata := a.runtimeMetadata()
|
||||
|
||||
return frameworkbootstrap.Run(ctx, frameworkbootstrap.Options{
|
||||
BinaryName: binaryName,
|
||||
Description: binaryDescription,
|
||||
BinaryName: metadata.BinaryName,
|
||||
Description: metadata.Description,
|
||||
Version: a.version,
|
||||
EnableDoctorAlias: true,
|
||||
Args: args,
|
||||
Stdin: a.stdin,
|
||||
Stdout: a.stdout,
|
||||
|
|
@ -145,6 +137,7 @@ func (a *App) runBootstrap(ctx context.Context, args []string) error {
|
|||
Setup: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
||||
return a.runConfig(ctx, frameworkbootstrap.CommandSetup, inv.Args)
|
||||
},
|
||||
Login: frameworkbootstrap.BitwardenLoginHandler(metadata.BinaryName),
|
||||
MCP: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
||||
return a.runMCP(ctx, inv.Args)
|
||||
},
|
||||
|
|
@ -152,7 +145,10 @@ func (a *App) runBootstrap(ctx context.Context, args []string) error {
|
|||
return a.runConfigShow(ctx, inv.Args)
|
||||
},
|
||||
ConfigTest: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
||||
return a.runConfigTest(ctx, inv.Args)
|
||||
return a.runDoctor(ctx, inv.Args)
|
||||
},
|
||||
ConfigDelete: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
||||
return a.runConfigDelete(ctx, inv.Args)
|
||||
},
|
||||
Update: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
||||
return a.runUpdate(ctx, inv.Args)
|
||||
|
|
@ -161,80 +157,6 @@ func (a *App) runBootstrap(ctx context.Context, args []string) error {
|
|||
})
|
||||
}
|
||||
|
||||
func isGlobalHelpCommand(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(args) != 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(args[0]) {
|
||||
case "help", "-h", "--help":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isDoctorHelpCommand(args []string) bool {
|
||||
if len(args) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
first := strings.TrimSpace(args[0])
|
||||
second := strings.TrimSpace(args[1])
|
||||
|
||||
if first == "help" && second == "doctor" {
|
||||
return true
|
||||
}
|
||||
if first == "doctor" && (second == "-h" || second == "--help") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) printGlobalHelp() error {
|
||||
if _, err := fmt.Fprintf(a.stdout, "%s\n\n", binaryDescription); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(a.stdout, "Usage:\n %s <command> [args]\n\n", binaryName); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(a.stdout, "Common commands:"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commands := []struct {
|
||||
name string
|
||||
description string
|
||||
}{
|
||||
{name: "setup", description: "Initialize or update local configuration."},
|
||||
{name: "mcp", description: "Run the MCP server over stdio."},
|
||||
{name: "config", description: "Inspect or test resolved configuration."},
|
||||
{name: "doctor", description: "Run local diagnostics."},
|
||||
{name: "update", description: "Run the self-update flow."},
|
||||
{name: "version", description: "Print the binary version."},
|
||||
}
|
||||
for _, command := range commands {
|
||||
if _, err := fmt.Fprintf(a.stdout, " %-7s %s\n", command.name, command.description); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err := fmt.Fprintf(a.stdout, "\nDetailed help: %s help <command>\n", binaryName)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) printDoctorHelp() error {
|
||||
_, err := fmt.Fprintf(
|
||||
a.stdout,
|
||||
"Usage:\n %s doctor [--profile NAME]\n\nRun local diagnostics for config, wallet, manifest, and IMAP connectivity.\n",
|
||||
binaryName,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) runConfig(ctx context.Context, command string, args []string) error {
|
||||
if a.prompter == nil {
|
||||
return fmt.Errorf("config prompter is not configured")
|
||||
|
|
@ -256,7 +178,7 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro
|
|||
return err
|
||||
}
|
||||
|
||||
profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
|
||||
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
profile := cfg.Profiles[profileName]
|
||||
|
||||
secrets, err := a.openSecretStore()
|
||||
|
|
@ -281,9 +203,24 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro
|
|||
return err
|
||||
}
|
||||
|
||||
if shouldPersistPassword(hasStoredPassword, storedPassword, cred.Password) {
|
||||
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, frameworksecretstore.ErrReadOnly):
|
||||
if strings.TrimSpace(os.Getenv(passwordEnv)) == "" {
|
||||
return newUserFacingError(
|
||||
fmt.Sprintf("secret backend is read-only; set %s and rerun `email-mcp setup`", passwordEnv),
|
||||
err,
|
||||
)
|
||||
}
|
||||
if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; password is provided via %s\n", passwordEnv); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
default:
|
||||
return mapAppError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Profiles == nil {
|
||||
cfg.Profiles = map[string]ProfileConfig{}
|
||||
|
|
@ -320,7 +257,7 @@ func (a *App) runConfigShow(ctx context.Context, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
|
||||
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
profile := cfg.Profiles[profileName]
|
||||
|
||||
secrets, err := a.openSecretStore()
|
||||
|
|
@ -328,7 +265,7 @@ func (a *App) runConfigShow(ctx context.Context, args []string) error {
|
|||
return mapAppError(err)
|
||||
}
|
||||
|
||||
resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName))
|
||||
resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName))
|
||||
if err != nil {
|
||||
var missingErr *frameworkcli.MissingRequiredValuesError
|
||||
if !errors.As(err, &missingErr) {
|
||||
|
|
@ -356,8 +293,62 @@ func (a *App) runConfigShow(ctx context.Context, args []string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (a *App) runConfigTest(ctx context.Context, args []string) error {
|
||||
return a.runDoctor(ctx, args)
|
||||
func (a *App) runConfigDelete(_ context.Context, args []string) error {
|
||||
if a.configStore == nil {
|
||||
return fmt.Errorf("config store is not configured")
|
||||
}
|
||||
if a.openSecretStore == nil {
|
||||
return fmt.Errorf("secret store is not configured")
|
||||
}
|
||||
|
||||
profileFlag, err := parseProfileArgs("config delete", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, _, err := a.configStore.LoadDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
secrets, err := a.openSecretStore()
|
||||
if err != nil {
|
||||
return mapAppError(err)
|
||||
}
|
||||
if err := secrets.DeleteSecret(passwordSecretName(profileName)); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, frameworksecretstore.ErrNotFound):
|
||||
case errors.Is(err, frameworksecretstore.ErrReadOnly):
|
||||
if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; %s cannot be deleted automatically\n", passwordEnv); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
default:
|
||||
return mapAppError(err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Profiles != nil {
|
||||
delete(cfg.Profiles, profileName)
|
||||
}
|
||||
if strings.TrimSpace(cfg.CurrentProfile) == profileName {
|
||||
cfg.CurrentProfile = nextCurrentProfile(cfg.Profiles, a.runtimeMetadata().DefaultProfile)
|
||||
}
|
||||
|
||||
configPath, err := a.configStore.SaveDefault(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(a.stdout, "profile %q deleted from %s\n", profileName, configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.CurrentProfile != "" {
|
||||
if _, err := fmt.Fprintf(a.stdout, "current profile: %s\n", cfg.CurrentProfile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) runMCP(ctx context.Context, args []string) error {
|
||||
|
|
@ -403,18 +394,13 @@ func (a *App) runUpdate(ctx context.Context, args []string) error {
|
|||
return fmt.Errorf("resolve executable path: %w", err)
|
||||
}
|
||||
|
||||
manifestFile, err := a.loadManifestForExecutable(executablePath)
|
||||
options, err := mcpgen.UpdateOptionsFrom(filepath.Dir(executablePath), a.version, a.stdout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options.ExecutablePath = executablePath
|
||||
|
||||
return frameworkupdate.Run(ctx, frameworkupdate.Options{
|
||||
CurrentVersion: a.version,
|
||||
ExecutablePath: executablePath,
|
||||
BinaryName: binaryName,
|
||||
ReleaseSource: manifestFile.Update.ReleaseSource(),
|
||||
Stdout: a.stdout,
|
||||
})
|
||||
return frameworkupdate.Run(ctx, options)
|
||||
}
|
||||
|
||||
func (a *App) loadManifestForExecutable(executablePath string) (frameworkmanifest.File, error) {
|
||||
|
|
@ -447,7 +433,7 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error)
|
|||
return secretstore.Credential{}, err
|
||||
}
|
||||
|
||||
profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
|
||||
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
profile := cfg.Profiles[profileName]
|
||||
|
||||
secrets, err := a.openSecretStore()
|
||||
|
|
@ -455,7 +441,7 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error)
|
|||
return secretstore.Credential{}, err
|
||||
}
|
||||
|
||||
resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName))
|
||||
resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName))
|
||||
if err != nil {
|
||||
var missingErr *frameworkcli.MissingRequiredValuesError
|
||||
if errors.As(err, &missingErr) {
|
||||
|
|
@ -480,68 +466,24 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error)
|
|||
return cred, nil
|
||||
}
|
||||
|
||||
func credentialFieldSpecs(profileName string) []frameworkcli.FieldSpec {
|
||||
return []frameworkcli.FieldSpec{
|
||||
{
|
||||
Name: "host",
|
||||
Required: true,
|
||||
Sources: []frameworkcli.ValueSource{
|
||||
frameworkcli.SourceEnv,
|
||||
frameworkcli.SourceConfig,
|
||||
},
|
||||
EnvKey: hostEnv,
|
||||
ConfigKey: "host",
|
||||
},
|
||||
{
|
||||
Name: "username",
|
||||
Required: true,
|
||||
Sources: []frameworkcli.ValueSource{
|
||||
frameworkcli.SourceEnv,
|
||||
frameworkcli.SourceConfig,
|
||||
},
|
||||
EnvKey: usernameEnv,
|
||||
ConfigKey: "username",
|
||||
},
|
||||
passwordFieldSpec(profileName),
|
||||
func profileFieldSpecs(profileName string) []frameworkcli.FieldSpec {
|
||||
specs := mcpgen.ResolveFieldSpecs(profileName)
|
||||
profileSpecs := make([]frameworkcli.FieldSpec, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
if spec.Name == "host" || spec.Name == "username" {
|
||||
profileSpecs = append(profileSpecs, spec)
|
||||
}
|
||||
}
|
||||
return profileSpecs
|
||||
}
|
||||
|
||||
func profileFieldSpecs() []frameworkcli.FieldSpec {
|
||||
return []frameworkcli.FieldSpec{
|
||||
{
|
||||
Name: "host",
|
||||
Required: true,
|
||||
Sources: []frameworkcli.ValueSource{
|
||||
frameworkcli.SourceEnv,
|
||||
frameworkcli.SourceConfig,
|
||||
},
|
||||
EnvKey: hostEnv,
|
||||
ConfigKey: "host",
|
||||
},
|
||||
{
|
||||
Name: "username",
|
||||
Required: true,
|
||||
Sources: []frameworkcli.ValueSource{
|
||||
frameworkcli.SourceEnv,
|
||||
frameworkcli.SourceConfig,
|
||||
},
|
||||
EnvKey: usernameEnv,
|
||||
ConfigKey: "username",
|
||||
},
|
||||
func passwordOnlyFieldSpecs(profileName string) []frameworkcli.FieldSpec {
|
||||
for _, spec := range mcpgen.ResolveFieldSpecs(profileName) {
|
||||
if spec.Name == "password" {
|
||||
return []frameworkcli.FieldSpec{spec}
|
||||
}
|
||||
}
|
||||
|
||||
func passwordFieldSpec(profileName string) frameworkcli.FieldSpec {
|
||||
return frameworkcli.FieldSpec{
|
||||
Name: "password",
|
||||
Required: true,
|
||||
Sources: []frameworkcli.ValueSource{
|
||||
frameworkcli.SourceEnv,
|
||||
frameworkcli.SourceSecret,
|
||||
},
|
||||
EnvKey: passwordEnv,
|
||||
SecretKey: passwordSecretName(profileName),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveCredentialFields(profile ProfileConfig, store secretStore, fields []frameworkcli.FieldSpec) (frameworkcli.Resolution, error) {
|
||||
|
|
@ -552,31 +494,11 @@ func resolveCredentialFields(profile ProfileConfig, store secretStore, fields []
|
|||
|
||||
return frameworkcli.ResolveFields(frameworkcli.ResolveOptions{
|
||||
Fields: fields,
|
||||
Lookup: func(source frameworkcli.ValueSource, key string) (string, bool, error) {
|
||||
switch source {
|
||||
case frameworkcli.SourceEnv:
|
||||
value, ok := os.LookupEnv(strings.TrimSpace(key))
|
||||
return value, ok, nil
|
||||
case frameworkcli.SourceConfig:
|
||||
value, ok := configValues[strings.TrimSpace(key)]
|
||||
return value, ok, nil
|
||||
case frameworkcli.SourceSecret:
|
||||
if store == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
value, err := store.GetSecret(strings.TrimSpace(key))
|
||||
if err != nil {
|
||||
if errors.Is(err, frameworksecretstore.ErrNotFound) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return value, true, nil
|
||||
default:
|
||||
return "", false, nil
|
||||
}
|
||||
},
|
||||
Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{
|
||||
Env: frameworkcli.EnvLookup(os.LookupEnv),
|
||||
Config: frameworkcli.ConfigMap(configValues),
|
||||
Secret: frameworkcli.SecretStore(store),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -616,9 +538,21 @@ func loadStoredPassword(store secretStore, profileName string) (string, bool, er
|
|||
}
|
||||
|
||||
func passwordSecretName(profileName string) string {
|
||||
for _, spec := range mcpgen.ResolveFieldSpecs(profileName) {
|
||||
if spec.Name == "password" && strings.TrimSpace(spec.SecretKey) != "" {
|
||||
return spec.SecretKey
|
||||
}
|
||||
}
|
||||
return "imap-password/" + strings.TrimSpace(profileName)
|
||||
}
|
||||
|
||||
func shouldPersistPassword(hasStoredPassword bool, storedPassword, newPassword string) bool {
|
||||
if !hasStoredPassword {
|
||||
return true
|
||||
}
|
||||
return storedPassword != newPassword
|
||||
}
|
||||
|
||||
func parseProfileArgs(command string, args []string) (string, error) {
|
||||
flagSet := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||
flagSet.SetOutput(io.Discard)
|
||||
|
|
@ -648,6 +582,103 @@ func parseUpdateArgs(args []string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
type runtimeMetadata struct {
|
||||
BinaryName string
|
||||
Description string
|
||||
DefaultProfile string
|
||||
}
|
||||
|
||||
func (a *App) runtimeMetadata() runtimeMetadata {
|
||||
metadata := runtimeMetadata{
|
||||
BinaryName: mcpgen.BinaryName,
|
||||
Description: mcpgen.DefaultDescription,
|
||||
DefaultProfile: fallbackProfile,
|
||||
}
|
||||
|
||||
if a.loadManifest == nil {
|
||||
return metadata
|
||||
}
|
||||
|
||||
file, err := a.loadRuntimeManifest()
|
||||
if err != nil {
|
||||
return metadata
|
||||
}
|
||||
|
||||
bootstrap := file.BootstrapInfo()
|
||||
if bootstrap.BinaryName != "" {
|
||||
metadata.BinaryName = bootstrap.BinaryName
|
||||
}
|
||||
if bootstrap.Description != "" {
|
||||
metadata.Description = bootstrap.Description
|
||||
}
|
||||
if bootstrap.DefaultProfile != "" {
|
||||
metadata.DefaultProfile = bootstrap.DefaultProfile
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (a *App) loadRuntimeManifest() (frameworkmanifest.File, error) {
|
||||
if a.loadManifest == nil {
|
||||
return frameworkmanifest.File{}, fmt.Errorf("manifest loader is not configured")
|
||||
}
|
||||
|
||||
if a.resolveExecutable != nil {
|
||||
executablePath, err := a.resolveExecutable()
|
||||
if err == nil {
|
||||
file, loadErr := a.loadManifestForExecutable(executablePath)
|
||||
if loadErr == nil {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file, _, err := a.loadManifest(".")
|
||||
if err != nil {
|
||||
return frameworkmanifest.File{}, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (a *App) resolveProfileName(profileFlag, currentProfile string) string {
|
||||
resolvedCurrent := strings.TrimSpace(currentProfile)
|
||||
if resolvedCurrent == "" {
|
||||
resolvedCurrent = strings.TrimSpace(a.runtimeMetadata().DefaultProfile)
|
||||
}
|
||||
|
||||
return frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), resolvedCurrent)
|
||||
}
|
||||
|
||||
func nextCurrentProfile(profiles map[string]ProfileConfig, preferred string) string {
|
||||
if len(profiles) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalizedPreferred := strings.TrimSpace(preferred)
|
||||
if normalizedPreferred != "" {
|
||||
if _, ok := profiles[normalizedPreferred]; ok {
|
||||
return normalizedPreferred
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := profiles[fallbackProfile]; ok {
|
||||
return fallbackProfile
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(profiles))
|
||||
for name := range profiles {
|
||||
if trimmed := strings.TrimSpace(name); trimmed != "" {
|
||||
names = append(names, trimmed)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
sort.Strings(names)
|
||||
return names[0]
|
||||
}
|
||||
|
||||
func mapAppError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
|
@ -657,10 +688,7 @@ func mapAppError(err error) error {
|
|||
case errors.Is(err, mcpserver.ErrCredentialsNotConfigured):
|
||||
return newUserFacingError("credentials not configured; run `email-mcp setup`", err)
|
||||
case errors.Is(err, frameworksecretstore.ErrBackendUnavailable):
|
||||
return newUserFacingError(
|
||||
fmt.Sprintf("%s is not available; configure a supported OS wallet and retry", frameworksecretstore.BackendName()),
|
||||
err,
|
||||
)
|
||||
return newUserFacingError(strings.TrimSpace(err.Error()), err)
|
||||
case errors.Is(err, frameworksecretstore.ErrReadOnly):
|
||||
return newUserFacingError("secret backend is read-only", err)
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
|
||||
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
|
||||
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
|
||||
"email-mcp/internal/imapclient"
|
||||
"email-mcp/internal/mcpserver"
|
||||
|
|
@ -87,9 +87,12 @@ type secretStoreStub struct {
|
|||
values map[string]string
|
||||
setErr error
|
||||
getErr error
|
||||
deleteErr error
|
||||
setName string
|
||||
setValue string
|
||||
setCalled bool
|
||||
delName string
|
||||
delCalled bool
|
||||
}
|
||||
|
||||
func (s *secretStoreStub) SetSecret(name, _ string, secret string) error {
|
||||
|
|
@ -118,6 +121,11 @@ func (s *secretStoreStub) GetSecret(name string) (string, error) {
|
|||
}
|
||||
|
||||
func (s *secretStoreStub) DeleteSecret(name string) error {
|
||||
s.delCalled = true
|
||||
s.delName = name
|
||||
if s.deleteErr != nil {
|
||||
return s.deleteErr
|
||||
}
|
||||
delete(s.values, name)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -150,7 +158,42 @@ func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) {
|
|||
}
|
||||
|
||||
text := output.String()
|
||||
for _, snippet := range []string{"Usage:", "doctor", "version"} {
|
||||
for _, snippet := range []string{"Usage:", "config", "version", `doctor Diagnostiquer la configuration locale. (alias de "config test").`} {
|
||||
if !strings.Contains(text, snippet) {
|
||||
t.Fatalf("help output missing %q: %q", snippet, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRunShowsManifestBootstrapMetadataInHelp(t *testing.T) {
|
||||
output := &bytes.Buffer{}
|
||||
app := NewAppWithDependencies(
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
func(string) (frameworkmanifest.File, string, error) {
|
||||
return frameworkmanifest.File{
|
||||
BinaryName: "email-mcp-custom",
|
||||
Bootstrap: frameworkmanifest.Bootstrap{
|
||||
Description: "Custom manifest description",
|
||||
},
|
||||
}, "/tmp/mcp.toml", nil
|
||||
},
|
||||
func() (string, error) { return "/tmp/bin/email-mcp-custom", nil },
|
||||
nil,
|
||||
output,
|
||||
&bytes.Buffer{},
|
||||
"dev",
|
||||
)
|
||||
|
||||
if err := app.Run(nil); err != nil {
|
||||
t.Fatalf("expected help to be rendered, got error %v", err)
|
||||
}
|
||||
|
||||
text := output.String()
|
||||
for _, snippet := range []string{"Custom manifest description", "email-mcp-custom <command>", "email-mcp-custom help <command>"} {
|
||||
if !strings.Contains(text, snippet) {
|
||||
t.Fatalf("help output missing %q: %q", snippet, text)
|
||||
}
|
||||
|
|
@ -176,7 +219,19 @@ func TestAppRunDoctorHelp(t *testing.T) {
|
|||
if err := app.Run([]string{"doctor", "--help"}); err != nil {
|
||||
t.Fatalf("doctor help returned error: %v", err)
|
||||
}
|
||||
if got := output.String(); !strings.Contains(got, "email-mcp doctor [--profile NAME]") {
|
||||
if got := output.String(); !strings.Contains(got, "email-mcp config test [args]") {
|
||||
t.Fatalf("unexpected doctor help output: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRunHelpDoctorUsesConfigTestHelp(t *testing.T) {
|
||||
output := &bytes.Buffer{}
|
||||
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev")
|
||||
|
||||
if err := app.Run([]string{"help", "doctor"}); err != nil {
|
||||
t.Fatalf("help doctor returned error: %v", err)
|
||||
}
|
||||
if got := output.String(); !strings.Contains(got, "email-mcp config test [args]") {
|
||||
t.Fatalf("unexpected doctor help output: %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -234,6 +289,56 @@ func TestAppRunSetupPromptsAndSavesProfile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAppRunSetupUsesManifestDefaultProfile(t *testing.T) {
|
||||
prompter := &configPrompterStub{
|
||||
credential: secretstore.Credential{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
},
|
||||
}
|
||||
cfgStore := &configStoreStub{}
|
||||
secrets := &secretStoreStub{}
|
||||
output := &bytes.Buffer{}
|
||||
|
||||
app := NewAppWithDependencies(
|
||||
prompter,
|
||||
cfgStore,
|
||||
func() (secretStore, error) { return secrets, nil },
|
||||
nil,
|
||||
nil,
|
||||
func(string) (frameworkmanifest.File, string, error) {
|
||||
return frameworkmanifest.File{
|
||||
Profiles: frameworkmanifest.Profiles{
|
||||
Default: "work",
|
||||
},
|
||||
}, "/tmp/mcp.toml", nil
|
||||
},
|
||||
func() (string, error) { return "/tmp/bin/email-mcp", nil },
|
||||
nil,
|
||||
output,
|
||||
&bytes.Buffer{},
|
||||
"dev",
|
||||
)
|
||||
|
||||
if err := app.Run([]string{"setup"}); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
if !secrets.setCalled {
|
||||
t.Fatal("expected password to be stored")
|
||||
}
|
||||
if secrets.setName != "imap-password/work" {
|
||||
t.Fatalf("unexpected secret name %q", secrets.setName)
|
||||
}
|
||||
if cfgStore.saved.CurrentProfile != "work" {
|
||||
t.Fatalf("current profile = %q, want work", cfgStore.saved.CurrentProfile)
|
||||
}
|
||||
if got := output.String(); !strings.Contains(got, `profile "work" saved`) {
|
||||
t.Fatalf("unexpected output %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRunConfigRequiresSubcommand(t *testing.T) {
|
||||
prompter := &configPrompterStub{
|
||||
credential: secretstore.Credential{
|
||||
|
|
@ -271,6 +376,119 @@ func TestAppRunConfigRequiresSubcommand(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAppRunConfigDeleteRemovesProfileAndSecret(t *testing.T) {
|
||||
cfgStore := &configStoreStub{
|
||||
cfg: frameworkconfig.FileConfig[ProfileConfig]{
|
||||
Version: frameworkconfig.CurrentVersion,
|
||||
CurrentProfile: "work",
|
||||
Profiles: map[string]ProfileConfig{
|
||||
"work": {
|
||||
Host: "imap.work.example.com",
|
||||
Username: "alice",
|
||||
},
|
||||
"default": {
|
||||
Host: "imap.default.example.com",
|
||||
Username: "alice",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
secrets := &secretStoreStub{
|
||||
values: map[string]string{
|
||||
"imap-password/work": "secret-work",
|
||||
},
|
||||
}
|
||||
output := &bytes.Buffer{}
|
||||
|
||||
app := NewAppWithDependencies(
|
||||
nil,
|
||||
cfgStore,
|
||||
func() (secretStore, error) { return secrets, nil },
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
output,
|
||||
&bytes.Buffer{},
|
||||
"dev",
|
||||
)
|
||||
|
||||
if err := app.Run([]string{"config", "delete", "--profile", "work"}); err != nil {
|
||||
t.Fatalf("config delete returned error: %v", err)
|
||||
}
|
||||
|
||||
if !secrets.delCalled {
|
||||
t.Fatal("expected password secret to be deleted")
|
||||
}
|
||||
if secrets.delName != "imap-password/work" {
|
||||
t.Fatalf("deleted secret = %q, want %q", secrets.delName, "imap-password/work")
|
||||
}
|
||||
if _, ok := cfgStore.saved.Profiles["work"]; ok {
|
||||
t.Fatalf("profile work should have been removed, got %#v", cfgStore.saved.Profiles)
|
||||
}
|
||||
if cfgStore.saved.CurrentProfile != "default" {
|
||||
t.Fatalf("current profile = %q, want default", cfgStore.saved.CurrentProfile)
|
||||
}
|
||||
|
||||
text := output.String()
|
||||
for _, needle := range []string{`profile "work" deleted`, "current profile: default"} {
|
||||
if !strings.Contains(text, needle) {
|
||||
t.Fatalf("output = %q, want substring %q", text, needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRunConfigDeleteIgnoresReadOnlySecretBackend(t *testing.T) {
|
||||
cfgStore := &configStoreStub{
|
||||
cfg: frameworkconfig.FileConfig[ProfileConfig]{
|
||||
Version: frameworkconfig.CurrentVersion,
|
||||
CurrentProfile: "work",
|
||||
Profiles: map[string]ProfileConfig{
|
||||
"work": {
|
||||
Host: "imap.work.example.com",
|
||||
Username: "alice",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
secrets := &secretStoreStub{
|
||||
deleteErr: frameworksecretstore.ErrReadOnly,
|
||||
}
|
||||
output := &bytes.Buffer{}
|
||||
|
||||
app := NewAppWithDependencies(
|
||||
nil,
|
||||
cfgStore,
|
||||
func() (secretStore, error) { return secrets, nil },
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
output,
|
||||
&bytes.Buffer{},
|
||||
"dev",
|
||||
)
|
||||
|
||||
if err := app.Run([]string{"config", "delete", "--profile", "work"}); err != nil {
|
||||
t.Fatalf("config delete returned error: %v", err)
|
||||
}
|
||||
if _, ok := cfgStore.saved.Profiles["work"]; ok {
|
||||
t.Fatalf("profile work should have been removed, got %#v", cfgStore.saved.Profiles)
|
||||
}
|
||||
|
||||
text := output.String()
|
||||
for _, needle := range []string{
|
||||
"secret backend is read-only; EMAIL_MCP_PASSWORD cannot be deleted automatically",
|
||||
`profile "work" deleted`,
|
||||
} {
|
||||
if !strings.Contains(text, needle) {
|
||||
t.Fatalf("output = %q, want substring %q", text, needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) {
|
||||
prompter := &capturingPrompterStub{
|
||||
credential: secretstore.Credential{
|
||||
|
|
@ -322,6 +540,85 @@ func TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAppRunSetupAllowsReadOnlySecretBackendWhenPasswordEnvIsSet(t *testing.T) {
|
||||
t.Setenv(passwordEnv, "env-secret")
|
||||
|
||||
prompter := &configPrompterStub{
|
||||
credential: secretstore.Credential{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: "new-secret",
|
||||
},
|
||||
}
|
||||
cfgStore := &configStoreStub{}
|
||||
secrets := &secretStoreStub{setErr: frameworksecretstore.ErrReadOnly}
|
||||
output := &bytes.Buffer{}
|
||||
|
||||
app := NewAppWithDependencies(
|
||||
prompter,
|
||||
cfgStore,
|
||||
func() (secretStore, error) { return secrets, nil },
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
output,
|
||||
&bytes.Buffer{},
|
||||
"dev",
|
||||
)
|
||||
|
||||
if err := app.Run([]string{"setup"}); err != nil {
|
||||
t.Fatalf("setup returned error: %v", err)
|
||||
}
|
||||
if !secrets.setCalled {
|
||||
t.Fatal("expected password write attempt")
|
||||
}
|
||||
if !cfgStore.saveCalled {
|
||||
t.Fatal("expected config to be saved")
|
||||
}
|
||||
if !strings.Contains(output.String(), "secret backend is read-only; password is provided via EMAIL_MCP_PASSWORD") {
|
||||
t.Fatalf("unexpected output: %q", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRunSetupFailsOnReadOnlySecretBackendWithoutPasswordEnv(t *testing.T) {
|
||||
prompter := &configPrompterStub{
|
||||
credential: secretstore.Credential{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: "new-secret",
|
||||
},
|
||||
}
|
||||
cfgStore := &configStoreStub{}
|
||||
secrets := &secretStoreStub{setErr: frameworksecretstore.ErrReadOnly}
|
||||
|
||||
app := NewAppWithDependencies(
|
||||
prompter,
|
||||
cfgStore,
|
||||
func() (secretStore, error) { return secrets, nil },
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
io.Discard,
|
||||
&bytes.Buffer{},
|
||||
"dev",
|
||||
)
|
||||
|
||||
err := app.Run([]string{"setup"})
|
||||
if err == nil {
|
||||
t.Fatal("expected setup to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "secret backend is read-only; set EMAIL_MCP_PASSWORD and rerun `email-mcp setup`") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfgStore.saveCalled {
|
||||
t.Fatal("config must not be saved when password cannot be persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRunConfigShowPrintsResolvedConfiguration(t *testing.T) {
|
||||
cfgStore := &configStoreStub{
|
||||
cfg: frameworkconfig.FileConfig[ProfileConfig]{
|
||||
|
|
@ -549,8 +846,9 @@ func TestAppRunUpdateLoadsManifestNearExecutable(t *testing.T) {
|
|||
if err := os.WriteFile(filepath.Join(tempDir, "mcp.toml"), []byte(`
|
||||
[update]
|
||||
source_name = "test"
|
||||
driver = "gitea"
|
||||
repository = "AI/email-mcp"
|
||||
base_url = "http://127.0.0.1:1"
|
||||
latest_release_url = "http://127.0.0.1:1/releases/latest"
|
||||
`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest returned error: %v", err)
|
||||
}
|
||||
|
|
@ -624,7 +922,9 @@ func TestAppRunDoctorRendersReportAndChecksConnectivity(t *testing.T) {
|
|||
manifestDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
|
||||
[update]
|
||||
latest_release_url = "https://example.com/releases/latest"
|
||||
driver = "gitea"
|
||||
repository = "AI/email-mcp"
|
||||
base_url = "https://gitea.lclr.dev"
|
||||
`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
|
@ -663,10 +963,9 @@ latest_release_url = "https://example.com/releases/latest"
|
|||
text := output.String()
|
||||
for _, needle := range []string{
|
||||
"[OK] config: config file is readable",
|
||||
"[OK] profile: resolved profile is complete",
|
||||
"[OK] password: stored password is present",
|
||||
"[OK] profile: required profile values are resolved",
|
||||
"[OK] connectivity: IMAP server is reachable",
|
||||
"Summary: 6 ok, 0 warning(s), 0 failure(s), 6 total",
|
||||
"Summary: 4 ok, 0 warning(s), 0 failure(s), 4 total",
|
||||
} {
|
||||
if !strings.Contains(text, needle) {
|
||||
t.Fatalf("output = %q, want substring %q", text, needle)
|
||||
|
|
@ -696,14 +995,6 @@ func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) {
|
|||
t.Fatalf("Save returned error: %v", err)
|
||||
}
|
||||
|
||||
manifestDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
|
||||
[update]
|
||||
latest_release_url = "https://example.com/releases/latest"
|
||||
`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
output := &bytes.Buffer{}
|
||||
app := NewAppWithDependencies(
|
||||
nil,
|
||||
|
|
@ -712,7 +1003,7 @@ latest_release_url = "https://example.com/releases/latest"
|
|||
func() mcpserver.MailService { return &doctorMailServiceStub{} },
|
||||
nil,
|
||||
nil,
|
||||
func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
|
||||
nil,
|
||||
nil,
|
||||
output,
|
||||
&bytes.Buffer{},
|
||||
|
|
@ -726,7 +1017,7 @@ latest_release_url = "https://example.com/releases/latest"
|
|||
if !strings.Contains(err.Error(), "doctor checks failed") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(output.String(), "[FAIL] password: stored password is missing") {
|
||||
if !strings.Contains(output.String(), "[FAIL] connectivity: cannot load IMAP credentials") {
|
||||
t.Fatalf("unexpected output: %q", output.String())
|
||||
}
|
||||
}
|
||||
|
|
@ -755,14 +1046,6 @@ func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) {
|
|||
t.Fatalf("Save returned error: %v", err)
|
||||
}
|
||||
|
||||
manifestDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
|
||||
[update]
|
||||
latest_release_url = "https://example.com/releases/latest"
|
||||
`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
output := &bytes.Buffer{}
|
||||
app := NewAppWithDependencies(
|
||||
nil,
|
||||
|
|
@ -771,7 +1054,7 @@ latest_release_url = "https://example.com/releases/latest"
|
|||
func() mcpserver.MailService { return &doctorMailServiceStub{} },
|
||||
nil,
|
||||
nil,
|
||||
func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
|
||||
nil,
|
||||
nil,
|
||||
output,
|
||||
&bytes.Buffer{},
|
||||
|
|
@ -781,11 +1064,12 @@ latest_release_url = "https://example.com/releases/latest"
|
|||
if err := app.Run([]string{"doctor"}); err != nil {
|
||||
t.Fatalf("doctor returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(output.String(), "[OK] password: password is provided via environment") {
|
||||
if !strings.Contains(output.String(), "[OK] connectivity: IMAP server is reachable") {
|
||||
t.Fatalf("unexpected output: %q", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
|
||||
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev")
|
||||
|
||||
|
|
@ -825,7 +1109,7 @@ func TestMapAppErrorMapsMissingCredentialError(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMapAppErrorMapsUnavailableWalletError(t *testing.T) {
|
||||
func TestMapAppErrorMapsUnavailableSecretBackendError(t *testing.T) {
|
||||
err := mapAppError(&frameworksecretstore.BackendUnavailableError{
|
||||
Policy: frameworksecretstore.BackendAuto,
|
||||
Required: "any keyring backend",
|
||||
|
|
@ -833,8 +1117,25 @@ func TestMapAppErrorMapsUnavailableWalletError(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("expected mapped error")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "wallet") {
|
||||
t.Fatalf("expected wallet guidance, got %v", err)
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "secret backend") {
|
||||
t.Fatalf("expected secret backend guidance, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapAppErrorPreservesBitwardenBackendDetails(t *testing.T) {
|
||||
err := mapAppError(fmt.Errorf(
|
||||
"cannot use bitwarden CLI command %q right now: %w",
|
||||
"bw",
|
||||
errors.Join(frameworksecretstore.ErrBackendUnavailable, frameworksecretstore.ErrBWLocked),
|
||||
))
|
||||
if err == nil {
|
||||
t.Fatal("expected mapped error")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "bitwarden") {
|
||||
t.Fatalf("expected bitwarden guidance, got %v", err)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "secret service") || strings.Contains(strings.ToLower(err.Error()), "kwallet") {
|
||||
t.Fatalf("unexpected keyring guidance: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -862,7 +1163,7 @@ func TestExecuteSetupWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) {
|
|||
if code := Execute(app, []string{"setup"}, stderr); code != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", code)
|
||||
}
|
||||
if got := strings.ToLower(stderr.String()); !strings.Contains(got, "wallet") {
|
||||
if got := strings.ToLower(stderr.String()); !strings.Contains(got, "secret backend") {
|
||||
t.Fatalf("unexpected stderr: %q", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,13 @@ package cli
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
||||
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
|
||||
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
"email-mcp/mcpgen"
|
||||
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
|
||||
)
|
||||
|
||||
func (a *App) runDoctor(ctx context.Context, args []string) error {
|
||||
|
|
@ -31,19 +27,11 @@ func (a *App) runDoctor(ctx context.Context, args []string) error {
|
|||
}
|
||||
|
||||
report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{
|
||||
ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](binaryName)),
|
||||
SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.frameworkSecretStoreFactory()),
|
||||
ManifestDir: a.doctorManifestDir(),
|
||||
ManifestValidator: func(file frameworkmanifest.File, _ string) []string {
|
||||
if strings.TrimSpace(file.Update.LatestReleaseURL) == "" {
|
||||
return []string{"update.latest_release_url must not be empty"}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)),
|
||||
SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.openSecretStore),
|
||||
ConnectivityCheck: a.doctorConnectivityCheck(profileFlag),
|
||||
ExtraChecks: []frameworkcli.DoctorCheck{
|
||||
a.doctorProfileCheck(profileFlag),
|
||||
a.doctorPasswordCheck(profileFlag),
|
||||
a.doctorRequiredProfileFieldsCheck(profileFlag),
|
||||
},
|
||||
})
|
||||
if err := frameworkcli.RenderDoctorReport(a.stdout, report); err != nil {
|
||||
|
|
@ -55,135 +43,44 @@ func (a *App) runDoctor(ctx context.Context, args []string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (a *App) frameworkSecretStoreFactory() func() (frameworksecretstore.Store, error) {
|
||||
return func() (frameworksecretstore.Store, error) {
|
||||
store, err := a.openSecretStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
}
|
||||
func (a *App) doctorRequiredProfileFieldsCheck(profileFlag string) frameworkcli.DoctorCheck {
|
||||
var (
|
||||
profileValues map[string]string
|
||||
loadErr error
|
||||
)
|
||||
|
||||
func (a *App) doctorManifestDir() string {
|
||||
if a.resolveExecutable == nil {
|
||||
return "."
|
||||
check := frameworkcli.RequiredResolvedFieldsCheck(frameworkcli.ResolveOptions{
|
||||
Fields: profileFieldSpecs(a.resolveDoctorProfileName(profileFlag)),
|
||||
Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{
|
||||
Env: frameworkcli.EnvLookup(os.LookupEnv),
|
||||
Config: func(key string) (string, bool, error) {
|
||||
if loadErr != nil {
|
||||
return "", false, loadErr
|
||||
}
|
||||
|
||||
executablePath, err := a.resolveExecutable()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return filepath.Dir(executablePath)
|
||||
}
|
||||
|
||||
func (a *App) doctorProfileCheck(profileFlag string) frameworkcli.DoctorCheck {
|
||||
return func(context.Context) frameworkcli.DoctorResult {
|
||||
if profileValues == nil {
|
||||
cfg, _, err := a.configStore.LoadDefault()
|
||||
if err != nil {
|
||||
return frameworkcli.DoctorResult{
|
||||
Name: "profile",
|
||||
Status: frameworkcli.DoctorStatusFail,
|
||||
Summary: "cannot load profile configuration",
|
||||
Detail: err.Error(),
|
||||
loadErr = err
|
||||
return "", false, loadErr
|
||||
}
|
||||
|
||||
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
profile := cfg.Profiles[profileName]
|
||||
profileValues = map[string]string{
|
||||
"host": profile.Host,
|
||||
"username": profile.Username,
|
||||
}
|
||||
}
|
||||
|
||||
profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
|
||||
resolution, err := resolveCredentialFields(cfg.Profiles[profileName], nil, profileFieldSpecs())
|
||||
if err != nil {
|
||||
var missingErr *frameworkcli.MissingRequiredValuesError
|
||||
if errors.As(err, &missingErr) {
|
||||
return frameworkcli.DoctorResult{
|
||||
Name: "profile",
|
||||
Status: frameworkcli.DoctorStatusFail,
|
||||
Summary: "resolved profile is incomplete",
|
||||
Detail: fmt.Sprintf("profile %q: missing %s", profileName, strings.Join(missingErr.Fields, ", ")),
|
||||
}
|
||||
}
|
||||
return frameworkcli.MapLookup(profileValues)(key)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
return frameworkcli.DoctorResult{
|
||||
Name: "profile",
|
||||
Status: frameworkcli.DoctorStatusFail,
|
||||
Summary: "cannot resolve profile values",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
host, _ := resolution.Get("host")
|
||||
username, _ := resolution.Get("username")
|
||||
|
||||
return frameworkcli.DoctorResult{
|
||||
Name: "profile",
|
||||
Status: frameworkcli.DoctorStatusOK,
|
||||
Summary: "resolved profile is complete",
|
||||
Detail: fmt.Sprintf(
|
||||
"profile %q (host: %s, username: %s)",
|
||||
profileName,
|
||||
host.Source,
|
||||
username.Source,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) doctorPasswordCheck(profileFlag string) frameworkcli.DoctorCheck {
|
||||
return func(context.Context) frameworkcli.DoctorResult {
|
||||
profileName := a.resolveDoctorProfileName(profileFlag)
|
||||
store, err := a.openSecretStore()
|
||||
if err != nil {
|
||||
return frameworkcli.DoctorResult{
|
||||
Name: "password",
|
||||
Status: frameworkcli.DoctorStatusFail,
|
||||
Summary: "cannot inspect stored password",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
resolution, err := resolveCredentialFields(
|
||||
ProfileConfig{},
|
||||
store,
|
||||
[]frameworkcli.FieldSpec{passwordFieldSpec(profileName)},
|
||||
)
|
||||
if err != nil {
|
||||
var missingErr *frameworkcli.MissingRequiredValuesError
|
||||
if errors.As(err, &missingErr) {
|
||||
return frameworkcli.DoctorResult{
|
||||
Name: "password",
|
||||
Status: frameworkcli.DoctorStatusFail,
|
||||
Summary: "stored password is missing",
|
||||
Detail: fmt.Sprintf(
|
||||
"set %q or secret %q",
|
||||
passwordEnv,
|
||||
passwordSecretName(profileName),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return frameworkcli.DoctorResult{
|
||||
Name: "password",
|
||||
Status: frameworkcli.DoctorStatusFail,
|
||||
Summary: "cannot read stored password",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
password, _ := resolution.Get("password")
|
||||
if password.Source == frameworkcli.SourceEnv {
|
||||
return frameworkcli.DoctorResult{
|
||||
Name: "password",
|
||||
Status: frameworkcli.DoctorStatusOK,
|
||||
Summary: "password is provided via environment",
|
||||
Detail: fmt.Sprintf("variable %q", passwordEnv),
|
||||
}
|
||||
}
|
||||
|
||||
return frameworkcli.DoctorResult{
|
||||
Name: "password",
|
||||
Status: frameworkcli.DoctorStatusOK,
|
||||
Summary: "stored password is present",
|
||||
Detail: fmt.Sprintf("secret %q", passwordSecretName(profileName)),
|
||||
}
|
||||
return func(ctx context.Context) frameworkcli.DoctorResult {
|
||||
result := check(ctx)
|
||||
result.Name = "profile"
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,15 +117,15 @@ func (a *App) doctorConnectivityCheck(profileFlag string) frameworkcli.DoctorChe
|
|||
}
|
||||
|
||||
func (a *App) resolveDoctorProfileName(profileFlag string) string {
|
||||
envProfile := os.Getenv(defaultProfileEnv)
|
||||
if a.configStore == nil {
|
||||
return frameworkcli.ResolveProfileName(profileFlag, envProfile, "")
|
||||
return a.resolveProfileName(profileFlag, "")
|
||||
}
|
||||
|
||||
cfg, _, err := a.configStore.LoadDefault()
|
||||
if err != nil {
|
||||
return frameworkcli.ResolveProfileName(profileFlag, envProfile, "")
|
||||
return a.resolveProfileName(profileFlag, "")
|
||||
}
|
||||
|
||||
return frameworkcli.ResolveProfileName(profileFlag, envProfile, cfg.CurrentProfile)
|
||||
return a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
||||
"email-mcp/mcpgen"
|
||||
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
|
||||
"email-mcp/internal/secretstore"
|
||||
)
|
||||
|
|
@ -76,32 +77,20 @@ func (p *InteractiveConfigPrompter) promptCredentialWithSetupEngine(existing sec
|
|||
password = existing.Password
|
||||
}
|
||||
|
||||
fields := mcpgen.SetupFields(map[string]string{"password": password})
|
||||
for i := range fields {
|
||||
switch fields[i].Name {
|
||||
case "host":
|
||||
fields[i].Default = existing.Host
|
||||
case "username":
|
||||
fields[i].Default = existing.Username
|
||||
}
|
||||
}
|
||||
|
||||
result, err := frameworkcli.RunSetup(frameworkcli.SetupOptions{
|
||||
Stdin: p.stdinFile,
|
||||
Stdout: p.output,
|
||||
Fields: []frameworkcli.SetupField{
|
||||
{
|
||||
Name: "host",
|
||||
Label: "IMAP host",
|
||||
Type: frameworkcli.SetupFieldString,
|
||||
Required: true,
|
||||
Default: existing.Host,
|
||||
},
|
||||
{
|
||||
Name: "username",
|
||||
Label: "Username",
|
||||
Type: frameworkcli.SetupFieldString,
|
||||
Required: true,
|
||||
Default: existing.Username,
|
||||
},
|
||||
{
|
||||
Name: "password",
|
||||
Label: "Password",
|
||||
Type: frameworkcli.SetupFieldSecret,
|
||||
Required: true,
|
||||
ExistingSecret: password,
|
||||
},
|
||||
},
|
||||
Fields: fields,
|
||||
})
|
||||
if err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import (
|
|||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
|
||||
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
"email-mcp/mcpgen"
|
||||
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
|
||||
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
|
||||
"email-mcp/internal/imapclient"
|
||||
"email-mcp/internal/mcpserver"
|
||||
|
|
@ -47,6 +48,7 @@ func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, version strin
|
|||
}
|
||||
|
||||
func (f runtimeFactories) withDefaults() runtimeFactories {
|
||||
useGeneratedManifest := f.loadManifest == nil
|
||||
if f.newPrompter == nil {
|
||||
f.newPrompter = func(input io.Reader, output io.Writer) ConfigPrompter {
|
||||
return NewInteractiveConfigPrompter(input, output)
|
||||
|
|
@ -54,14 +56,29 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
|
|||
}
|
||||
if f.newConfigStore == nil {
|
||||
f.newConfigStore = func() profileConfigStore {
|
||||
return frameworkconfig.NewStore[ProfileConfig]("email-mcp")
|
||||
return frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)
|
||||
}
|
||||
}
|
||||
if f.loadManifest == nil {
|
||||
f.loadManifest = mcpgen.LoadManifest
|
||||
}
|
||||
if f.resolveExecutable == nil {
|
||||
f.resolveExecutable = os.Executable
|
||||
}
|
||||
if f.openSecretStore == nil {
|
||||
f.openSecretStore = func() (secretStore, error) {
|
||||
return frameworksecretstore.Open(frameworksecretstore.Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: frameworksecretstore.BackendAuto,
|
||||
if useGeneratedManifest {
|
||||
return mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{
|
||||
ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable),
|
||||
LookupEnv: profilePasswordLookupEnv,
|
||||
})
|
||||
}
|
||||
|
||||
return frameworksecretstore.OpenFromManifest(frameworksecretstore.OpenFromManifestOptions{
|
||||
ServiceName: mcpgen.BinaryName,
|
||||
ManifestLoader: frameworksecretstore.ManifestLoader(f.loadManifest),
|
||||
ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable),
|
||||
LookupEnv: profilePasswordLookupEnv,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -75,16 +92,18 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
|
|||
return mcpserver.NewRunner(mcpserver.New(staticCredentialStore{credential: cred}, mail), input, output, errOut)
|
||||
}
|
||||
}
|
||||
if f.loadManifest == nil {
|
||||
f.loadManifest = frameworkmanifest.LoadDefault
|
||||
}
|
||||
if f.resolveExecutable == nil {
|
||||
f.resolveExecutable = os.Executable
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func profilePasswordLookupEnv(name string) (string, bool) {
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
if strings.HasPrefix(trimmedName, "imap-password/") {
|
||||
return os.LookupEnv(passwordEnv)
|
||||
}
|
||||
return os.LookupEnv(trimmedName)
|
||||
}
|
||||
|
||||
type staticCredentialStore struct {
|
||||
credential secretstore.Credential
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestBuildAppReturnsConfiguredApp(t *testing.T) {
|
||||
app := BuildApp("dev")
|
||||
|
|
@ -26,3 +31,52 @@ func TestBuildAppReturnsConfiguredApp(t *testing.T) {
|
|||
t.Fatal("expected manifest loader to be configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAppOpenSecretStoreMapsProfilePasswordToEnvironment(t *testing.T) {
|
||||
t.Setenv(passwordEnv, "env-secret")
|
||||
|
||||
app := buildApp(nil, nil, nil, "dev", runtimeFactories{
|
||||
loadManifest: func(string) (frameworkmanifest.File, string, error) {
|
||||
return frameworkmanifest.File{
|
||||
SecretStore: frameworkmanifest.SecretStore{
|
||||
BackendPolicy: "env-only",
|
||||
},
|
||||
}, "/tmp/mcp.toml", nil
|
||||
},
|
||||
resolveExecutable: func() (string, error) { return "/tmp/bin/email-mcp", nil },
|
||||
})
|
||||
|
||||
store, err := app.openSecretStore()
|
||||
if err != nil {
|
||||
t.Fatalf("openSecretStore returned error: %v", err)
|
||||
}
|
||||
|
||||
value, err := store.GetSecret("imap-password/work")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if value != "env-secret" {
|
||||
t.Fatalf("GetSecret = %q, want %q", value, "env-secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAppOpenSecretStoreReturnsErrorOnInvalidManifestPolicy(t *testing.T) {
|
||||
app := buildApp(nil, nil, nil, "dev", runtimeFactories{
|
||||
loadManifest: func(string) (frameworkmanifest.File, string, error) {
|
||||
return frameworkmanifest.File{
|
||||
SecretStore: frameworkmanifest.SecretStore{
|
||||
BackendPolicy: "invalid-policy",
|
||||
},
|
||||
}, "/tmp/mcp.toml", nil
|
||||
},
|
||||
resolveExecutable: func() (string, error) { return "/tmp/bin/email-mcp", nil },
|
||||
})
|
||||
|
||||
_, err := app.openSecretStore()
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid secret store policy error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid secret_store.backend_policy") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
60
mcp.toml
60
mcp.toml
|
|
@ -1,4 +1,60 @@
|
|||
binary_name = "email-mcp"
|
||||
docs_url = "https://forge.lclr.dev/AI/email-mcp"
|
||||
|
||||
[update]
|
||||
source_name = "email-mcp releases"
|
||||
base_url = "https://gitea.lclr.dev"
|
||||
latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/latest"
|
||||
driver = "gitea"
|
||||
repository = "AI/email-mcp"
|
||||
base_url = "https://forge.lclr.dev"
|
||||
asset_name_template = "{binary}-{os}-{arch}{ext}"
|
||||
checksum_asset_name = "{asset}.sha256"
|
||||
checksum_required = true
|
||||
token_env_names = ["GITEA_TOKEN"]
|
||||
|
||||
[environment]
|
||||
known = [
|
||||
"EMAIL_MCP_PROFILE",
|
||||
"EMAIL_MCP_HOST",
|
||||
"EMAIL_MCP_USERNAME",
|
||||
"EMAIL_MCP_PASSWORD",
|
||||
"BW_SESSION",
|
||||
"MCP_FRAMEWORK_BITWARDEN_CACHE",
|
||||
"MCP_FRAMEWORK_BITWARDEN_DEBUG",
|
||||
]
|
||||
|
||||
[secret_store]
|
||||
backend_policy = "bitwarden-cli"
|
||||
|
||||
[profiles]
|
||||
default = "default"
|
||||
known = ["default"]
|
||||
|
||||
[bootstrap]
|
||||
description = "Local MCP server to read an IMAP mailbox."
|
||||
|
||||
[[config.fields]]
|
||||
name = "host"
|
||||
env = "EMAIL_MCP_HOST"
|
||||
config_key = "host"
|
||||
type = "string"
|
||||
label = "IMAP host"
|
||||
required = true
|
||||
sources = ["env", "config"]
|
||||
|
||||
[[config.fields]]
|
||||
name = "username"
|
||||
env = "EMAIL_MCP_USERNAME"
|
||||
config_key = "username"
|
||||
type = "string"
|
||||
label = "Username"
|
||||
required = true
|
||||
sources = ["env", "config"]
|
||||
|
||||
[[config.fields]]
|
||||
name = "password"
|
||||
env = "EMAIL_MCP_PASSWORD"
|
||||
secret_key_template = "imap-password/{profile}"
|
||||
type = "secret"
|
||||
label = "Password"
|
||||
required = true
|
||||
sources = ["env", "secret"]
|
||||
|
|
|
|||
63
mcpgen/config.go
Normal file
63
mcpgen/config.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package mcpgen
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strings"
|
||||
|
||||
fwcli "forge.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),
|
||||
}
|
||||
|
||||
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{
|
||||
{Name: "host", Required: true, DefaultValue: "", Sources: []fwcli.ValueSource{fwcli.SourceEnv, fwcli.SourceConfig}, FlagKey: "", EnvKey: "EMAIL_MCP_HOST", ConfigKey: "host", SecretKey: replaceProfile("", profile)},
|
||||
{Name: "username", Required: true, DefaultValue: "", Sources: []fwcli.ValueSource{fwcli.SourceEnv, fwcli.SourceConfig}, FlagKey: "", EnvKey: "EMAIL_MCP_USERNAME", ConfigKey: "username", SecretKey: replaceProfile("", profile)},
|
||||
{Name: "password", Required: true, DefaultValue: "", Sources: []fwcli.ValueSource{fwcli.SourceEnv, fwcli.SourceSecret}, FlagKey: "", EnvKey: "EMAIL_MCP_PASSWORD", ConfigKey: "", SecretKey: replaceProfile("imap-password/{profile}", profile)},
|
||||
}
|
||||
}
|
||||
|
||||
func SetupFields(existing map[string]string) []fwcli.SetupField {
|
||||
if existing == nil {
|
||||
existing = map[string]string{}
|
||||
}
|
||||
|
||||
return []fwcli.SetupField{
|
||||
{Name: "host", Label: "IMAP host", Type: fwcli.SetupFieldString, Required: true, Default: "", ExistingSecret: existing["host"]},
|
||||
{Name: "username", Label: "Username", Type: fwcli.SetupFieldString, Required: true, Default: "", ExistingSecret: existing["username"]},
|
||||
{Name: "password", Label: "Password", Type: fwcli.SetupFieldSecret, Required: true, Default: "", ExistingSecret: existing["password"]},
|
||||
}
|
||||
}
|
||||
|
||||
func replaceProfile(value, profile string) string {
|
||||
return strings.ReplaceAll(value, "{profile}", strings.TrimSpace(profile))
|
||||
}
|
||||
71
mcpgen/generated_test.go
Normal file
71
mcpgen/generated_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package mcpgen
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestGeneratedManifestFallsBackToEmbeddedRootManifest(t *testing.T) {
|
||||
previousDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd returned error: %v", err)
|
||||
}
|
||||
if err := os.Chdir(t.TempDir()); err != nil {
|
||||
t.Fatalf("Chdir temp dir returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chdir(previousDir); err != nil {
|
||||
t.Fatalf("restore working directory: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
manifestFile, source, err := 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 manifestFile.BinaryName != "email-mcp" {
|
||||
t.Fatalf("BinaryName = %q, want email-mcp", manifestFile.BinaryName)
|
||||
}
|
||||
if len(manifestFile.Config.Fields) != 3 {
|
||||
t.Fatalf("config fields = %d, want 3", len(manifestFile.Config.Fields))
|
||||
}
|
||||
if manifestFile.SecretStore.BackendPolicy != "bitwarden-cli" {
|
||||
t.Fatalf("secret store backend policy = %q, want bitwarden-cli", manifestFile.SecretStore.BackendPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedConfigHelpersExposeIMAPResolutionFields(t *testing.T) {
|
||||
specs := ResolveFieldSpecs("work")
|
||||
if len(specs) != 3 {
|
||||
t.Fatalf("ResolveFieldSpecs returned %d fields, want 3", len(specs))
|
||||
}
|
||||
|
||||
if specs[0].Name != "host" || specs[0].EnvKey != "EMAIL_MCP_HOST" || specs[0].ConfigKey != "host" {
|
||||
t.Fatalf("host spec = %+v", specs[0])
|
||||
}
|
||||
if specs[1].Name != "username" || specs[1].EnvKey != "EMAIL_MCP_USERNAME" || specs[1].ConfigKey != "username" {
|
||||
t.Fatalf("username spec = %+v", specs[1])
|
||||
}
|
||||
if specs[2].Name != "password" || specs[2].EnvKey != "EMAIL_MCP_PASSWORD" || specs[2].SecretKey != "imap-password/work" {
|
||||
t.Fatalf("password spec = %+v", specs[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedManifestPrefersRootFileWhenPresent(t *testing.T) {
|
||||
manifestFile, source, err := LoadManifest(".")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadManifest returned error: %v", err)
|
||||
}
|
||||
if source == fwmanifest.EmbeddedSource {
|
||||
t.Fatalf("source = %q, want root manifest path", source)
|
||||
}
|
||||
if manifestFile.BinaryName != "email-mcp" {
|
||||
t.Fatalf("BinaryName = %q, want email-mcp", manifestFile.BinaryName)
|
||||
}
|
||||
}
|
||||
11
mcpgen/manifest.go
Normal file
11
mcpgen/manifest.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package mcpgen
|
||||
|
||||
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
|
||||
const embeddedManifest = "binary_name = \"email-mcp\"\ndocs_url = \"https://gitea.lclr.dev/AI/email-mcp\"\n\n[update]\nsource_name = \"email-mcp releases\"\ndriver = \"gitea\"\nrepository = \"AI/email-mcp\"\nbase_url = \"https://gitea.lclr.dev\"\nasset_name_template = \"{binary}-{os}-{arch}{ext}\"\nchecksum_asset_name = \"{asset}.sha256\"\nchecksum_required = true\ntoken_env_names = [\"GITEA_TOKEN\"]\n\n[environment]\nknown = [\n \"EMAIL_MCP_PROFILE\",\n \"EMAIL_MCP_HOST\",\n \"EMAIL_MCP_USERNAME\",\n \"EMAIL_MCP_PASSWORD\",\n \"BW_SESSION\",\n \"MCP_FRAMEWORK_BITWARDEN_CACHE\",\n \"MCP_FRAMEWORK_BITWARDEN_DEBUG\",\n]\n\n[secret_store]\nbackend_policy = \"bitwarden-cli\"\n\n[profiles]\ndefault = \"default\"\nknown = [\"default\"]\n\n[bootstrap]\ndescription = \"Local MCP server to read an IMAP mailbox.\"\n\n[[config.fields]]\nname = \"host\"\nenv = \"EMAIL_MCP_HOST\"\nconfig_key = \"host\"\ntype = \"string\"\nlabel = \"IMAP host\"\nrequired = true\nsources = [\"env\", \"config\"]\n\n[[config.fields]]\nname = \"username\"\nenv = \"EMAIL_MCP_USERNAME\"\nconfig_key = \"username\"\ntype = \"string\"\nlabel = \"Username\"\nrequired = true\nsources = [\"env\", \"config\"]\n\n[[config.fields]]\nname = \"password\"\nenv = \"EMAIL_MCP_PASSWORD\"\nsecret_key_template = \"imap-password/{profile}\"\ntype = \"secret\"\nlabel = \"Password\"\nrequired = true\nsources = [\"env\", \"secret\"]\n"
|
||||
|
||||
func LoadManifest(startDir string) (fwmanifest.File, string, error) {
|
||||
return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)
|
||||
}
|
||||
27
mcpgen/metadata.go
Normal file
27
mcpgen/metadata.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package mcpgen
|
||||
|
||||
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
|
||||
const BinaryName = "email-mcp"
|
||||
const DefaultDescription = "Local MCP server to read an IMAP mailbox."
|
||||
const DocsURL = "https://gitea.lclr.dev/AI/email-mcp"
|
||||
|
||||
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
|
||||
}
|
||||
91
mcpgen/secretstore.go
Normal file
91
mcpgen/secretstore.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package mcpgen
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
fwsecretstore "forge.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
|
||||
}
|
||||
59
mcpgen/update.go
Normal file
59
mcpgen/update.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package mcpgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
fwupdate "forge.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)
|
||||
}
|
||||
Loading…
Reference in a new issue