Compare commits
95 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e0e1f6de6 | ||
| 7c999d2aba | |||
| 846894c1a7 | |||
| 7c016e8c5e | |||
| 90dbed4d37 | |||
| 078aa17285 | |||
|
|
200674778b | ||
| 267b83bd0c | |||
| 92b63fe83d | |||
| 64671fc8b2 | |||
| 9ac814fda4 | |||
| 39b2bfbcf9 | |||
| ea3a37559a | |||
| 4e2bfbee02 | |||
| 3a61387215 | |||
| b9b729e439 | |||
| f8eb0d3449 | |||
| e6c372bffc | |||
| d23d79b6c1 | |||
| 9a52b5dce1 | |||
| 4a7248cfa9 | |||
| 955c96650a | |||
| cd0740c75f | |||
| 6bf9dd1866 | |||
| 6e85969cf4 | |||
| 1e11181c02 | |||
| 893600ffd5 | |||
| 0135b093a5 | |||
| 5552e63974 | |||
| fd08615950 | |||
| 85da274772 | |||
| 1a44a2ea35 | |||
| 9675490cd3 | |||
| e5f2244ad8 | |||
| e99a1c109a | |||
| afe4c681a1 | |||
| 17b1b99686 | |||
| a79f73825f | |||
| 20b5026f9d | |||
| ef22b1aa8a | |||
| 017005b0b1 | |||
| 6e80d3418e | |||
| 2920f5980a | |||
| 98bac84ab8 | |||
| 98f07f557d | |||
| 7d159bfdbd | |||
| 7072cb2038 | |||
| bba7aacedf | |||
| 973770ed78 | |||
| a9378885f2 | |||
| 01c0c7e1bc | |||
| f0e2e9304b | |||
| fbff660bcc | |||
| 0d266cd5cc | |||
| 3eeb2fe173 | |||
| 9d862c876f | |||
| b2eebf413e | |||
| f80eebb575 | |||
| 7c239a7e97 | |||
| 5e115893cc | |||
| f5e52463f2 | |||
| 845d20541b | |||
| 0e5bfb2d39 | |||
| 3d8a7dc84d | |||
| ea1768982e | |||
| 26238eb31b | |||
| 8c4f88ea93 | |||
| 1b603f552c | |||
| d42a790bc0 | |||
| c4c461105f | |||
| 46f48cb1f6 | |||
| bf8e1285d8 | |||
| 42e1345962 | |||
| cae689d0e4 | |||
| 17fe329ce9 | |||
| 89246d1581 | |||
| 1c40546f3f | |||
| fcf8d71349 | |||
| 3dab8000c1 | |||
| 34abd29bac | |||
| 11fc89dc71 | |||
| 76fa01c669 | |||
| c6c38d6019 | |||
| c70b3ac12b | |||
| 6badc79d4d | |||
| c0433d1cde | |||
| 392394d513 | |||
| 4cb7de6b24 | |||
| 97d5f78009 | |||
| e9cfad5469 | |||
| 3437d265d4 | |||
| c90c22e1de | |||
| 70b4058584 | |||
|
|
7543a6514d | ||
|
|
246e63ec2b |
63 changed files with 18860 additions and 434 deletions
25
.forgejo/workflows/ci.yml
Normal file
25
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: CI
|
||||
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run go test
|
||||
run: go test ./...
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
164
.forgejo/workflows/release.yml
Normal file
164
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
name: Release
|
||||
|
||||
"on":
|
||||
push:
|
||||
tags:
|
||||
- "**"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
releases: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract changelog and update CHANGELOG.md
|
||||
id: changelog
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
current_tag="${GITHUB_REF_NAME}"
|
||||
today=$(date +%Y-%m-%d)
|
||||
|
||||
# Extract content of [Unreleased] section (non-empty lines)
|
||||
release_notes=$(awk '/^## \[Unreleased\]/{found=1; next} found && /^## \[/{exit} found{print}' CHANGELOG.md | sed '/^[[:space:]]*$/d')
|
||||
|
||||
if [ -z "${release_notes}" ]; then
|
||||
release_notes="Voir les commits pour le détail des changements."
|
||||
fi
|
||||
|
||||
printf '%s\n' "${release_notes}" > release_notes.md
|
||||
|
||||
# For stable releases: rename [Unreleased] and insert a new empty section
|
||||
case "${current_tag}" in
|
||||
*-rc*|*-beta*|*-alpha*)
|
||||
echo "Pre-release tag — CHANGELOG.md non modifié"
|
||||
;;
|
||||
*)
|
||||
# Rename [Unreleased] → version header
|
||||
sed -i "s/^## \[Unreleased\]$/## [${current_tag}] — ${today}/" CHANGELOG.md
|
||||
|
||||
# Insert new empty [Unreleased] section after "# Changelog"
|
||||
sed -i "s/^# Changelog$/# Changelog\n\n## [Unreleased]/" CHANGELOG.md
|
||||
|
||||
# Insert reference link before the first existing [vX...] link
|
||||
awk -v tag="${current_tag}" -v url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${current_tag}" '
|
||||
!inserted && /^\[v/ { print "[" tag "]: " url; inserted=1 }
|
||||
{ print }
|
||||
' CHANGELOG.md > CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md
|
||||
|
||||
git config user.name "CI"
|
||||
git config user.email "ci@forge.lclr.dev"
|
||||
scheme="${GITHUB_SERVER_URL%%://*}"
|
||||
host="${GITHUB_SERVER_URL#*://}"
|
||||
git remote set-url origin "${scheme}://x-token:${GITEA_TOKEN}@${host}/${GITHUB_REPOSITORY}.git"
|
||||
git add CHANGELOG.md
|
||||
git commit -m "chore(changelog): release ${current_tag}"
|
||||
git push origin HEAD:main
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Create or update release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
current_tag="${GITHUB_REF_NAME}"
|
||||
api_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases"
|
||||
release_by_tag_url="${api_url}/tags/${current_tag}"
|
||||
prerelease=false
|
||||
|
||||
case "${current_tag}" in
|
||||
*-rc*|*-beta*|*-alpha*)
|
||||
prerelease=true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Build commit log since previous stable tag
|
||||
previous_stable_tag=""
|
||||
if previous_stable_tag="$(
|
||||
git describe --tags --abbrev=0 \
|
||||
--exclude '*-rc*' \
|
||||
--exclude '*-beta*' \
|
||||
--exclude '*-alpha*' \
|
||||
"${current_tag}^" 2>/dev/null
|
||||
)"; then
|
||||
range="${previous_stable_tag}..${current_tag}"
|
||||
else
|
||||
range="${current_tag}"
|
||||
fi
|
||||
|
||||
{
|
||||
cat release_notes.md
|
||||
printf '\n\n## Commits\n\n'
|
||||
git log --reverse --pretty=format:'- %h %s' "${range}"
|
||||
printf '\n'
|
||||
} > release_body.md
|
||||
|
||||
json_escape() {
|
||||
sed ':a;N;$!ba;s/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g;s/\r//g;s/\n/\\n/g'
|
||||
}
|
||||
|
||||
body="$(json_escape < release_body.md)"
|
||||
payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \
|
||||
"${current_tag}" \
|
||||
"${current_tag}" \
|
||||
"${body}" \
|
||||
"${prerelease}")"
|
||||
|
||||
http_code="$(
|
||||
curl --silent --show-error --output release.json --write-out '%{http_code}' \
|
||||
--header "Authorization: token ${GITEA_TOKEN}" \
|
||||
--header 'Accept: application/json' \
|
||||
"${release_by_tag_url}"
|
||||
)"
|
||||
|
||||
case "${http_code}" in
|
||||
200)
|
||||
release_id="$(
|
||||
tr '{,' '\n' < release.json |
|
||||
sed -n 's/^[[:space:]]*"id":[[:space:]]*\([0-9][0-9]*\)$/\1/p' |
|
||||
head -n1
|
||||
)"
|
||||
|
||||
if [ -z "${release_id}" ]; then
|
||||
echo "failed to parse release id from existing release response" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --silent --show-error --fail \
|
||||
--request PATCH \
|
||||
--header "Authorization: token ${GITEA_TOKEN}" \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "${payload}" \
|
||||
"${api_url}/${release_id}" >/dev/null
|
||||
;;
|
||||
404)
|
||||
curl --silent --show-error --fail \
|
||||
--request POST \
|
||||
--header "Authorization: token ${GITEA_TOKEN}" \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "${payload}" \
|
||||
"${api_url}" >/dev/null
|
||||
;;
|
||||
*)
|
||||
echo "unexpected status code while looking up release: ${http_code}" >&2
|
||||
cat release.json >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
name: Release
|
||||
|
||||
"on":
|
||||
push:
|
||||
tags:
|
||||
- "**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
releases: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build changelog
|
||||
id: changelog
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
current_tag="${GITHUB_REF_NAME}"
|
||||
previous_tag=""
|
||||
|
||||
if previous_tag="$(git describe --tags --abbrev=0 "${current_tag}^" 2>/dev/null)"; then
|
||||
range="${previous_tag}..${current_tag}"
|
||||
{
|
||||
printf '## Changelog\n\n'
|
||||
printf 'Changes since `%s`.\n\n' "${previous_tag}"
|
||||
git log --reverse --pretty=format:'- %h %s' "${range}"
|
||||
printf '\n'
|
||||
} >CHANGELOG.md
|
||||
else
|
||||
{
|
||||
printf '## Changelog\n\n'
|
||||
printf 'Initial release.\n\n'
|
||||
git log --reverse --pretty=format:'- %h %s' "${current_tag}"
|
||||
printf '\n'
|
||||
} >CHANGELOG.md
|
||||
fi
|
||||
|
||||
- name: Create or update release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
current_tag="${GITHUB_REF_NAME}"
|
||||
api_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases"
|
||||
release_by_tag_url="${api_url}/tags/${current_tag}"
|
||||
|
||||
json_escape() {
|
||||
sed ':a;N;$!ba;s/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g;s/\r//g;s/\n/\\n/g'
|
||||
}
|
||||
|
||||
body="$(json_escape < CHANGELOG.md)"
|
||||
payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":false}' \
|
||||
"${current_tag}" \
|
||||
"${current_tag}" \
|
||||
"${body}")"
|
||||
|
||||
http_code="$(
|
||||
curl --silent --show-error --output release.json --write-out '%{http_code}' \
|
||||
--header "Authorization: token ${GITEA_TOKEN}" \
|
||||
--header 'Accept: application/json' \
|
||||
"${release_by_tag_url}"
|
||||
)"
|
||||
|
||||
case "${http_code}" in
|
||||
200)
|
||||
release_id="$(
|
||||
tr '{,' '\n' < release.json |
|
||||
sed -n 's/^[[:space:]]*"id":[[:space:]]*\([0-9][0-9]*\)$/\1/p' |
|
||||
head -n1
|
||||
)"
|
||||
|
||||
if [ -z "${release_id}" ]; then
|
||||
echo "failed to parse release id from existing release response" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --silent --show-error --fail \
|
||||
--request PATCH \
|
||||
--header "Authorization: token ${GITEA_TOKEN}" \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "${payload}" \
|
||||
"${api_url}/${release_id}" >/dev/null
|
||||
;;
|
||||
404)
|
||||
curl --silent --show-error --fail \
|
||||
--request POST \
|
||||
--header "Authorization: token ${GITEA_TOKEN}" \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "${payload}" \
|
||||
"${api_url}" >/dev/null
|
||||
;;
|
||||
*)
|
||||
echo "unexpected status code while looking up release: ${http_code}" >&2
|
||||
cat release.json >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
70
AGENTS.md
Normal file
70
AGENTS.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# AGENTS.md
|
||||
|
||||
Ces instructions s'appliquent à tout le dépôt `mcp-framework`.
|
||||
|
||||
## Forge
|
||||
|
||||
Le dépôt est hébergé sur Gitea.
|
||||
|
||||
Pour les actions distantes liées au dépôt, utiliser `tea` plutôt qu'un autre CLI.
|
||||
Exemples : lire une issue, s'assigner une issue, créer une branche de travail, ouvrir une PR, commenter une PR.
|
||||
|
||||
## Issues
|
||||
|
||||
Quand on commence à travailler sur une issue, il faut se l'assigner immédiatement.
|
||||
|
||||
Si le travail correspond directement à une issue existante :
|
||||
|
||||
1. lire l'issue
|
||||
2. se l'assigner
|
||||
3. créer une branche dédiée
|
||||
4. implémenter et valider localement
|
||||
5. pousser la branche
|
||||
6. ouvrir une PR liée à l'issue
|
||||
|
||||
## Branches
|
||||
|
||||
Préférer une branche de travail dédiée par issue ou sujet.
|
||||
|
||||
Nommer la branche de manière explicite, par exemple :
|
||||
|
||||
- `issue-4-structured-secrets`
|
||||
- `issue-8-update-drivers`
|
||||
- `docs-readme-installation`
|
||||
|
||||
Quand une branche est créée pour répondre à une issue et que cette issue porte le label `enhancement`, nommer la branche au format `feat/<issue_short_name>`.
|
||||
|
||||
Éviter de développer directement sur `main` quand le changement mérite une PR ou une validation fonctionnelle.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
Par défaut, quand un changement doit être validé via un vrai use case, une intégration avec un autre dépôt, ou une revue fonctionnelle, ouvrir une PR plutôt que pousser directement sur `main`.
|
||||
|
||||
Le corps de PR doit en général contenir :
|
||||
|
||||
- un résumé clair de ce qui a été implémenté
|
||||
- la validation locale déjà effectuée
|
||||
- une proposition de test manuel ou d'intégration si pertinente
|
||||
- le lien explicite avec l'issue, idéalement via `Closes #<numéro>`
|
||||
|
||||
Si le changement a un impact possible sur un dépôt consommateur comme `graylog-mcp` ou `email-mcp`, ajouter dans la PR ou dans un commentaire de PR un plan de validation ou d'adaptation pour ce dépôt.
|
||||
|
||||
## Validation
|
||||
|
||||
Avant d'ouvrir ou de mettre à jour une PR :
|
||||
|
||||
- exécuter les tests locaux pertinents
|
||||
- signaler clairement ce qui a été validé
|
||||
- signaler explicitement ce qui n'a pas pu être testé
|
||||
|
||||
## Commits
|
||||
|
||||
Conserver des messages de commit au format conventional commits, conformément aux règles globales.
|
||||
|
||||
## Changelog
|
||||
|
||||
Après chaque développement fonctionnel, mettre à jour la section `## [Unreleased]` du `CHANGELOG.md` avec une description claire des changements apportés.
|
||||
|
||||
Ne pas logger les changements purement liés à la CI ou au changelog lui-même.
|
||||
|
||||
La CI se charge de versioner automatiquement la section `[Unreleased]` lors du push d'un tag stable.
|
||||
240
CHANGELOG.md
Normal file
240
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.13.0] — 2026-05-13
|
||||
|
||||
### Corrections
|
||||
|
||||
- **Secretstore — isolation des sessions Bitwarden entre MCPs** : `bw unlock` n'est plus appelé si le vault est déjà ouvert et qu'une session valide existe (env ou fichier partagé). Le login écrit uniquement dans `~/.config/mcp-framework/bw-session` — fichier commun à tous les MCPs — évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage.
|
||||
|
||||
## [v1.12.0] — 2026-05-13
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Bootstrap — `DefaultLoginHandler`** : handler de login Bitwarden prêt à l'emploi avec confirmation, évitant de réimplémenter le même code dans chaque MCP.
|
||||
- **Bootstrap — `StandardConfigTestHandler`** : handler de config test standard sans `ManifestCheck`. Accepte `ConfigCheck`, `OpenStore`, `ConnectivityCheck` et `ExtraChecks`.
|
||||
- **CLI — `ManifestCheck` opt-in dans `RunDoctor`** : le check de manifeste n'est inclus que si `ManifestDir` est fourni, supprimant une contrainte runtime inutile.
|
||||
|
||||
### Changements cassants
|
||||
|
||||
- **Bootstrap — `DefaultLoginHandler` renommé en `BitwardenLoginHandler`** : le nom précédent suggérait à tort que le handler s'applique à tous les MCPs. Les projets sans backend Bitwarden ne définissent pas de hook Login — la commande est masquée automatiquement par `autoDisabledCommands`.
|
||||
|
||||
## [v1.11.0] — 2026-05-12
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Bootstrap — masquage automatique des commandes non configurées** : les commandes dont aucun hook n'est défini dans le manifeste sont désormais automatiquement masquées de l'aide CLI, ce qui rend la sortie `--help` plus propre et adaptée à chaque projet.
|
||||
- **Bootstrap — option `DisabledCommands`** : il est maintenant possible de désactiver explicitement des commandes via l'option `DisabledCommands`, indépendamment de la configuration des hooks.
|
||||
|
||||
---
|
||||
|
||||
## [v1.10.0] — 2026-05-11
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Bootstrap — `DefaultLoginHandler`** : un handler de login générique est désormais disponible dans le package bootstrap, utilisable par défaut pour les projets qui n'ont pas besoin d'un flux de connexion personnalisé.
|
||||
|
||||
### Corrections
|
||||
|
||||
- Renommage du dossier `.forgejo` corrigé suite à une erreur de casse.
|
||||
|
||||
---
|
||||
|
||||
## [v1.9.0] — 2026-05-05
|
||||
|
||||
### Changements internes
|
||||
|
||||
- **Migration vers Forgejo** : les workflows CI ont été migrés de GitHub Actions vers Forgejo.
|
||||
- **Mise à jour du module path** : le chemin du module Go a été mis à jour pour pointer vers la forge interne.
|
||||
|
||||
---
|
||||
|
||||
## [v1.8.2] — 2026-05-02
|
||||
|
||||
### Performances
|
||||
|
||||
- Suppression d'un appel de sonde Bitwarden inutile lors de la génération de la description runtime, ce qui réduit les appels CLI superflus au démarrage.
|
||||
|
||||
---
|
||||
|
||||
## [v1.8.1] — 2026-05-02
|
||||
|
||||
### Performances
|
||||
|
||||
- La vérification de disponibilité de Bitwarden est désormais chargée de manière paresseuse (lazy), évitant une initialisation coûteuse si le secret store n'est pas utilisé.
|
||||
|
||||
---
|
||||
|
||||
## [v1.8.0] — 2026-05-02
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Cache Bitwarden chiffré** : les lectures de secrets Bitwarden sont maintenant mises en cache localement sous forme chiffrée, ce qui réduit considérablement le nombre d'appels au CLI `bw` durant une session.
|
||||
- **Configuration du cache via `mcp.toml`** : les options de cache (durée, activation) sont configurables directement dans le manifeste du projet.
|
||||
- **Helpers générés** : les helpers de code générés exposent les contrôles de cache Bitwarden, permettant aux projets scaffoldés de bénéficier automatiquement du cache.
|
||||
|
||||
---
|
||||
|
||||
## [v1.7.0] — 2026-05-02
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Génération de code depuis le manifeste** : le framework peut désormais générer automatiquement du code Go à partir du `mcp.toml`, incluant les helpers de champs de configuration et le code de glue pour les helpers de manifeste. Cela réduit le boilerplate dans les projets utilisant le framework.
|
||||
|
||||
---
|
||||
|
||||
## [v1.6.0] — 2026-04-20
|
||||
|
||||
### Corrections
|
||||
|
||||
- L'invite de connexion Bitwarden s'affiche désormais en rouge lorsque la session est absente, rendant l'état d'erreur plus visible pour l'utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## [v1.5.1] — 2026-04-16
|
||||
|
||||
### Améliorations
|
||||
|
||||
- Le script d'installation généré par le scaffold récupère désormais les binaires depuis la dernière release disponible, plutôt qu'une version fixée en dur.
|
||||
|
||||
---
|
||||
|
||||
## [v1.5.0] — 2026-04-16
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Fallback runtime embarqué pour les apps scaffoldées** : si le binaire runtime n'est pas trouvé dans l'environnement, les applications générées par le scaffold peuvent désormais utiliser un runtime de fallback embarqué directement dans le manifeste.
|
||||
|
||||
---
|
||||
|
||||
## [v1.4.2] — 2026-04-15
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Vérification Ed25519 des artefacts de release** : les artefacts téléchargés lors des mises à jour automatiques sont désormais vérifiés par signature Ed25519, garantissant leur intégrité.
|
||||
|
||||
### Corrections
|
||||
|
||||
- Le mécanisme de mise à jour (`self-update`) rejette maintenant les artefacts HTML (erreurs de redirection ou pages d'erreur) pour éviter d'installer un binaire corrompu.
|
||||
- Durcissement du runtime scaffold et de la sécurité du processus de mise à jour.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Le README a été réorganisé et la documentation détaillée déplacée dans des fichiers séparés.
|
||||
|
||||
---
|
||||
|
||||
## [v1.4.1] — 2026-04-15
|
||||
|
||||
### Améliorations
|
||||
|
||||
- L'assistant d'installation généré par le scaffold est aligné avec le dernier flux TUI, garantissant la cohérence entre le code généré et le comportement attendu.
|
||||
|
||||
---
|
||||
|
||||
## [v1.4.0] — 2026-04-15
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Assistant d'installation TUI pour Claude et Codex** : le scaffold injecte désormais un wizard interactif (TUI) dans le script `install.sh` des projets générés, guidant l'utilisateur lors de la première installation avec des étapes de configuration pour Claude et Codex.
|
||||
|
||||
---
|
||||
|
||||
## [v1.3.2] — 2026-04-15
|
||||
|
||||
### Corrections
|
||||
|
||||
- Revert des fonctionnalités `build` unifiée et matrice CI introduites en cours de cycle, jugées non stables pour cette release.
|
||||
|
||||
---
|
||||
|
||||
## [v1.3.1] — 2026-04-14
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **CLI — vérification doctor sur les champs de profil** : un helper réutilisable permet de valider les champs de configuration résolus depuis plusieurs sources (env, fichier, défaut) lors du diagnostic `doctor`.
|
||||
- **CLI — lookup multi-sources** : ajout d'un helper de résolution de valeur avec traçabilité de la source (d'où vient la valeur résolue).
|
||||
- **Secretstore — helper de manifeste runtime** : ajout d'un helper facilitant l'ouverture du backend secret store depuis le manifeste runtime.
|
||||
- **Bootstrap — expansion d'alias de commandes** : les commandes bootstrap peuvent maintenant définir des alias qui sont développés automatiquement.
|
||||
|
||||
---
|
||||
|
||||
## [v1.3.0] — 2026-04-14
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Commande `scaffold init`** : nouvelle commande CLI pour initialiser un projet MCP depuis zéro via le scaffold.
|
||||
- **Générateur de scaffold MCP** : ajout d'un générateur de projet binaire MCP complet, produisant la structure de fichiers, le manifeste `mcp.toml`, et le code de démarrage.
|
||||
|
||||
---
|
||||
|
||||
## [v1.2.1] — 2026-04-14
|
||||
|
||||
### Améliorations
|
||||
|
||||
- Les commandes `config show` et `config test` générées par le bootstrap suivent désormais une structure standardisée cohérente entre les projets.
|
||||
|
||||
### Corrections
|
||||
|
||||
- La CI construit le changelog depuis le dernier tag de release stable (et non depuis un tag RC).
|
||||
|
||||
---
|
||||
|
||||
## [v1.2.0] — 2026-04-14
|
||||
|
||||
### Documentation
|
||||
|
||||
- Mise en place de la convention de nommage des branches d'amélioration dans les instructions agents du dépôt.
|
||||
|
||||
---
|
||||
|
||||
## [v1.1.0] — 2026-04-13
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Migrations de configuration versionnées** : le framework gère désormais les migrations de configuration entre versions, permettant aux projets d'évoluer leur schéma de config sans casser les installations existantes.
|
||||
- **Secrets structurés et politiques de backend** : support des secrets structurés (objets, non plus uniquement des chaînes) et des politiques de sélection de backend secret store par champ.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Ajout des instructions de workflow du dépôt.
|
||||
|
||||
---
|
||||
|
||||
## [v1.0.0] — 2026-04-13
|
||||
|
||||
Première release stable du framework.
|
||||
|
||||
### Fonctionnalités initiales
|
||||
|
||||
- **Framework MCP réutilisable** : socle commun pour construire des serveurs MCP en Go, avec gestion du cycle de vie, configuration, et intégration des outils.
|
||||
- **Loader de manifeste TOML** : chargement de la configuration projet depuis un fichier `mcp.toml`.
|
||||
- **Package de mise à jour** : mécanisme de self-update découplé, pilotable par des drivers de forge (GitLab, Forgejo, etc.) avec validation de checksum.
|
||||
- **Bootstrap CLI optionnel** : package permettant de bootstrapper rapidement une CLI pour un projet MCP, avec commandes `config`, `login`, `doctor`, et `update` préconfigurées.
|
||||
- **Workflow de release CI** : pipeline de release automatisée avec génération de changelog et publication des artefacts.
|
||||
|
||||
---
|
||||
|
||||
[v1.13.0]: http://forgejo:3000/AI/mcp-framework/releases/tag/v1.13.0
|
||||
[v1.12.0]: http://forgejo:3000/AI/mcp-framework/releases/tag/v1.12.0
|
||||
[v1.11.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.11.0
|
||||
[v1.10.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.10.0
|
||||
[v1.9.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.9.0
|
||||
[v1.8.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.2
|
||||
[v1.8.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.1
|
||||
[v1.8.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.0
|
||||
[v1.7.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.7.0
|
||||
[v1.6.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.6.0
|
||||
[v1.5.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.5.1
|
||||
[v1.5.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.5.0
|
||||
[v1.4.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.2
|
||||
[v1.4.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.1
|
||||
[v1.4.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.0
|
||||
[v1.3.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.2
|
||||
[v1.3.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.1
|
||||
[v1.3.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.0
|
||||
[v1.2.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.2.1
|
||||
[v1.2.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.2.0
|
||||
[v1.1.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.1.0
|
||||
[v1.0.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.0.0
|
||||
1
CLAUDE.md
Symbolic link
1
CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
289
README.md
289
README.md
|
|
@ -1,259 +1,90 @@
|
|||
# mcp-framework
|
||||
|
||||
Bibliothèque Go pour construire des binaires MCP avec :
|
||||
|
||||
- résolution de profils CLI
|
||||
- stockage JSON de configuration dans `os.UserConfigDir()`
|
||||
- stockage de secrets dans le wallet natif selon l'OS
|
||||
- lecture d'un manifeste `mcp.toml` à la racine du projet
|
||||
- pipeline d'auto-update via endpoint de release configurable
|
||||
|
||||
Le framework est volontairement petit. Il fournit des briques réutilisables,
|
||||
pas une application MCP complète.
|
||||
`mcp-framework` est une bibliothèque Go et un petit CLI pour construire des
|
||||
binaires MCP avec une base commune : CLI, configuration locale, secrets,
|
||||
manifeste `mcp.toml`, diagnostic et auto-update.
|
||||
|
||||
## Installation
|
||||
|
||||
Dans un projet Go :
|
||||
|
||||
```bash
|
||||
go get gitea.lclr.dev/AI/mcp-framework
|
||||
go get forge.lclr.dev/AI/mcp-framework
|
||||
```
|
||||
|
||||
## Packages
|
||||
Pour utiliser le CLI :
|
||||
|
||||
- `cli` : helpers pour résoudre un profil, valider une URL et demander des valeurs à l'utilisateur.
|
||||
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
|
||||
- `manifest` : lecture de `mcp.toml` à la racine du projet et conversion vers `update.ReleaseSource`.
|
||||
- `secretstore` : lecture/écriture de secrets dans le wallet natif.
|
||||
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
|
||||
|
||||
## Utilisation type
|
||||
|
||||
Le flux typique côté application est :
|
||||
|
||||
1. Résoudre le profil actif avec `cli`.
|
||||
2. Charger la config versionnée avec `config`.
|
||||
3. Lire les secrets avec `secretstore`.
|
||||
4. Charger `mcp.toml` avec `manifest`.
|
||||
5. Exécuter l'auto-update avec `update` si nécessaire.
|
||||
|
||||
## Manifeste `mcp.toml`
|
||||
|
||||
Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire
|
||||
courant puis remonte les répertoires parents jusqu'à trouver le fichier.
|
||||
|
||||
Exemple minimal :
|
||||
|
||||
```toml
|
||||
[update]
|
||||
source_name = "Gitea releases"
|
||||
base_url = "https://gitea.example.com"
|
||||
latest_release_url = "https://gitea.example.com/api/v1/repos/org/repo/releases/latest"
|
||||
token_header = "Authorization"
|
||||
token_env_names = ["GITEA_TOKEN"]
|
||||
```bash
|
||||
go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
||||
```
|
||||
|
||||
Champs supportés dans `[update]` :
|
||||
## Créer un projet MCP
|
||||
|
||||
- `source_name` : nom humain de la source de release, utilisé dans certains messages d'erreur.
|
||||
- `base_url` : base de la forge ou du service de release.
|
||||
- `latest_release_url` : URL complète qui retourne la release la plus récente.
|
||||
- `token_header` : header HTTP à utiliser pour l'authentification.
|
||||
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
|
||||
```bash
|
||||
mcp-framework scaffold init \
|
||||
--target ./my-mcp \
|
||||
--module example.com/my-mcp \
|
||||
--binary my-mcp \
|
||||
--profiles dev,prod
|
||||
|
||||
Exemple de chargement :
|
||||
|
||||
```go
|
||||
file, path, err := manifest.LoadDefault(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("manifest loaded from %s\n", path)
|
||||
source := file.Update.ReleaseSource()
|
||||
cd my-mcp
|
||||
go mod tidy
|
||||
go run ./cmd/my-mcp help
|
||||
```
|
||||
|
||||
## Config JSON
|
||||
Le scaffold crée une arborescence prête à adapter :
|
||||
|
||||
Le package `config` stocke une structure générique par profil dans un JSON privé
|
||||
pour l'utilisateur courant.
|
||||
|
||||
Exemple :
|
||||
|
||||
```go
|
||||
type Profile struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
store := config.NewStore[Profile]("my-mcp")
|
||||
|
||||
cfg, path, err := store.LoadDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
|
||||
profile := cfg.Profiles[profileName]
|
||||
|
||||
profile.BaseURL = "https://api.example.com"
|
||||
cfg.CurrentProfile = profileName
|
||||
cfg.Profiles[profileName] = profile
|
||||
|
||||
_, err = store.SaveDefault(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("config saved to %s\n", path)
|
||||
```text
|
||||
cmd/<binary>/main.go
|
||||
internal/app/app.go
|
||||
mcp.toml
|
||||
install.sh
|
||||
README.md
|
||||
```
|
||||
|
||||
Notes :
|
||||
## Générer la glue depuis `mcp.toml`
|
||||
|
||||
- le fichier est créé avec des permissions `0600`
|
||||
- le répertoire parent est forcé en `0700`
|
||||
- l'écriture est atomique via un fichier temporaire puis `rename`
|
||||
- si le fichier n'existe pas, `Load` et `LoadDefault` retournent une config vide par défaut
|
||||
Dans un projet qui possède un `mcp.toml` à la racine :
|
||||
|
||||
## Secrets
|
||||
|
||||
Le package `secretstore` s'appuie sur le wallet natif du système :
|
||||
|
||||
- macOS : Keychain
|
||||
- Linux : Secret Service ou KWallet selon l'environnement
|
||||
- Windows : Credential Manager
|
||||
|
||||
Exemple :
|
||||
|
||||
```go
|
||||
store, err := secretstore.Open(secretstore.Options{
|
||||
ServiceName: "my-mcp",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := store.SetSecret("api-token", "My MCP API token", token); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err = store.GetSecret("api-token")
|
||||
switch {
|
||||
case err == nil:
|
||||
// secret found
|
||||
case errors.Is(err, secretstore.ErrNotFound):
|
||||
// first run
|
||||
default:
|
||||
return err
|
||||
}
|
||||
```bash
|
||||
mcp-framework generate
|
||||
```
|
||||
|
||||
## Helpers CLI
|
||||
La commande génère un package `mcpgen/` avec un loader de manifeste embarqué,
|
||||
des helpers de métadonnées, update, secret store, et des helpers de config si
|
||||
`[[config.fields]]` est déclaré.
|
||||
|
||||
`cli` fournit des helpers simples pour les assistants interactifs :
|
||||
En CI :
|
||||
|
||||
```go
|
||||
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
|
||||
|
||||
baseURL, err := cli.PromptLine(reader, os.Stdout, "Base URL", profile.BaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cli.ValidateBaseURL(baseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := cli.PromptSecret(os.Stdin, os.Stdout, "API token", hasStoredSecret, storedToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```bash
|
||||
mcp-framework generate --check
|
||||
```
|
||||
|
||||
## Auto-Update
|
||||
## Utiliser les packages
|
||||
|
||||
Le package `update` ne déduit pas la forge ni l'authentification.
|
||||
L'application cliente fournit l'URL de release, le header d'auth éventuel et,
|
||||
si besoin, les variables d'environnement à consulter.
|
||||
Les packages peuvent être utilisés séparément :
|
||||
|
||||
Le format attendu pour la réponse `latest release` est actuellement :
|
||||
- `bootstrap` : CLI commune (`setup`, `login`, `mcp`, `config`, `update`, `version`).
|
||||
- `cli` : résolution de profil, setup interactif, résolution `flag/env/config/secret`, doctor.
|
||||
- `config` : stockage JSON versionné dans le répertoire de config utilisateur.
|
||||
- `manifest` : lecture de `mcp.toml` et fallback embarqué.
|
||||
- `secretstore` : keyring natif, environnement ou Bitwarden CLI.
|
||||
- `update` : téléchargement et remplacement du binaire depuis une release.
|
||||
- `scaffold` : génération d'un squelette de projet.
|
||||
- `generate` : génération de code Go depuis `mcp.toml`.
|
||||
|
||||
```json
|
||||
{
|
||||
"tag_name": "v1.2.3",
|
||||
"assets": {
|
||||
"links": [
|
||||
{
|
||||
"name": "my-mcp-linux-amd64",
|
||||
"url": "https://example.com/downloads/my-mcp-linux-amd64"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
## Documentation
|
||||
|
||||
Exemple :
|
||||
|
||||
```go
|
||||
file, _, err := manifest.LoadDefault(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = update.Run(ctx, update.Options{
|
||||
CurrentVersion: version,
|
||||
BinaryName: "my-mcp",
|
||||
ReleaseSource: file.Update.ReleaseSource(),
|
||||
Stdout: os.Stdout,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Contraintes actuelles :
|
||||
|
||||
- le `latest_release_url` doit être renseigné explicitement
|
||||
- les assets supportés sont `darwin/amd64`, `darwin/arm64`, `linux/amd64` et `windows/amd64`
|
||||
- le remplacement du binaire n'est pas supporté sur Windows
|
||||
- le nom de l'asset est dérivé de `BinaryName`, `GOOS` et `GOARCH`
|
||||
|
||||
## Exemple Minimal
|
||||
|
||||
```go
|
||||
type Profile struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
|
||||
func run(ctx context.Context, flagProfile string) error {
|
||||
cfgStore := config.NewStore[Profile]("my-mcp")
|
||||
|
||||
cfg, _, err := cfgStore.LoadDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
|
||||
profile := cfg.Profiles[profileName]
|
||||
|
||||
manifestFile, _, err := manifest.LoadDefault(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = update.Run(ctx, update.Options{
|
||||
CurrentVersion: version,
|
||||
BinaryName: "my-mcp",
|
||||
ReleaseSource: manifestFile.Update.ReleaseSource(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = profile
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Limites Actuelles
|
||||
|
||||
- le manifeste gère uniquement la section `[update]`
|
||||
- le framework ne fournit pas encore d'interface unique de bootstrap
|
||||
- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes
|
||||
- [Vue d'ensemble](docs/README.md)
|
||||
- [Installation et utilisation](docs/getting-started.md)
|
||||
- [Packages](docs/packages.md)
|
||||
- [Bootstrap CLI](docs/bootstrap-cli.md)
|
||||
- [Manifeste `mcp.toml`](docs/manifest.md)
|
||||
- [Génération depuis `mcp.toml`](docs/generate.md)
|
||||
- [Scaffolding](docs/scaffolding.md)
|
||||
- [Config JSON](docs/config.md)
|
||||
- [Secrets](docs/secrets.md)
|
||||
- [Helpers CLI](docs/cli-helpers.md)
|
||||
- [Auto-update](docs/auto-update.md)
|
||||
- [Exemple minimal](docs/minimal-example.md)
|
||||
- [Limites](docs/limitations.md)
|
||||
|
|
|
|||
620
bootstrap/bootstrap.go
Normal file
620
bootstrap/bootstrap.go
Normal file
|
|
@ -0,0 +1,620 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
CommandSetup = "setup"
|
||||
CommandLogin = "login"
|
||||
CommandMCP = "mcp"
|
||||
CommandConfig = "config"
|
||||
CommandUpdate = "update"
|
||||
CommandVersion = "version"
|
||||
CommandDoctor = "doctor"
|
||||
|
||||
bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG"
|
||||
|
||||
ConfigSubcommandShow = "show"
|
||||
ConfigSubcommandTest = "test"
|
||||
ConfigSubcommandDelete = "delete"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBinaryNameRequired = errors.New("binary name is required")
|
||||
ErrUnknownCommand = errors.New("unknown command")
|
||||
ErrUnknownSubcommand = errors.New("unknown subcommand")
|
||||
ErrCommandNotConfigured = errors.New("command not configured")
|
||||
ErrVersionRequired = errors.New("version is required when no version hook is configured")
|
||||
ErrSubcommandRequired = errors.New("subcommand is required")
|
||||
)
|
||||
|
||||
type Handler func(context.Context, Invocation) error
|
||||
|
||||
type Hooks struct {
|
||||
Setup Handler
|
||||
Login Handler
|
||||
MCP Handler
|
||||
Config Handler
|
||||
ConfigShow Handler
|
||||
ConfigTest Handler
|
||||
ConfigDelete Handler
|
||||
Update Handler
|
||||
Version Handler
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
BinaryName string
|
||||
Description string
|
||||
Version string
|
||||
Aliases map[string][]string
|
||||
AliasDescriptions map[string]string
|
||||
EnableDoctorAlias bool
|
||||
DisabledCommands []string
|
||||
Args []string
|
||||
Stdin io.Reader
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Hooks Hooks
|
||||
}
|
||||
|
||||
type Invocation struct {
|
||||
Command string
|
||||
Args []string
|
||||
Stdin io.Reader
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
}
|
||||
|
||||
type commandDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Handler func(Hooks) Handler
|
||||
}
|
||||
|
||||
var commands = []commandDef{
|
||||
{
|
||||
Name: CommandSetup,
|
||||
Description: "Initialiser ou mettre a jour la configuration locale.",
|
||||
Handler: func(h Hooks) Handler {
|
||||
return h.Setup
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: CommandLogin,
|
||||
Description: "Authentifier et deverrouiller Bitwarden pour persister BW_SESSION.",
|
||||
Handler: func(h Hooks) Handler {
|
||||
return h.Login
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: CommandMCP,
|
||||
Description: "Executer la logique MCP principale du binaire.",
|
||||
Handler: func(h Hooks) Handler {
|
||||
return h.MCP
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: CommandConfig,
|
||||
Description: "Inspecter ou modifier la configuration.",
|
||||
Handler: func(h Hooks) Handler {
|
||||
return h.Config
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: CommandUpdate,
|
||||
Description: "Declencher le flux d'auto-update.",
|
||||
Handler: func(h Hooks) Handler {
|
||||
return h.Update
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: CommandVersion,
|
||||
Description: "Afficher la version du binaire.",
|
||||
Handler: func(h Hooks) Handler {
|
||||
return h.Version
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, opts Options) error {
|
||||
normalized := normalize(opts)
|
||||
|
||||
if strings.TrimSpace(normalized.BinaryName) == "" {
|
||||
return ErrBinaryNameRequired
|
||||
}
|
||||
|
||||
resolvedArgs := expandAliases(normalized.Args, normalized.Aliases)
|
||||
resolvedArgs, debugEnabled := extractGlobalDebugFlag(resolvedArgs)
|
||||
if debugEnabled {
|
||||
_ = os.Setenv(bitwardenDebugEnvName, "1")
|
||||
}
|
||||
|
||||
command, commandArgs, showHelp := parseArgs(resolvedArgs)
|
||||
if showHelp {
|
||||
return printHelp(normalized, command, commandArgs)
|
||||
}
|
||||
|
||||
if command == "" {
|
||||
return printHelp(normalized, "")
|
||||
}
|
||||
|
||||
if isCommandDisabled(command, normalized.DisabledCommands) {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
||||
}
|
||||
|
||||
if command == CommandConfig {
|
||||
return runConfigCommand(ctx, normalized, commandArgs)
|
||||
}
|
||||
|
||||
handler, known := resolveHandler(command, normalized.Hooks)
|
||||
if !known {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
||||
}
|
||||
|
||||
if handler == nil {
|
||||
switch command {
|
||||
case CommandVersion:
|
||||
if strings.TrimSpace(normalized.Version) == "" {
|
||||
return ErrVersionRequired
|
||||
}
|
||||
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
|
||||
}
|
||||
}
|
||||
|
||||
return handler(ctx, Invocation{
|
||||
Command: command,
|
||||
Args: commandArgs,
|
||||
Stdin: normalized.Stdin,
|
||||
Stdout: normalized.Stdout,
|
||||
Stderr: normalized.Stderr,
|
||||
})
|
||||
}
|
||||
|
||||
func normalize(opts Options) Options {
|
||||
if opts.Stdin == nil {
|
||||
opts.Stdin = os.Stdin
|
||||
}
|
||||
if opts.Stdout == nil {
|
||||
opts.Stdout = os.Stdout
|
||||
}
|
||||
if opts.Stderr == nil {
|
||||
opts.Stderr = os.Stderr
|
||||
}
|
||||
if opts.Args == nil {
|
||||
opts.Args = os.Args[1:]
|
||||
}
|
||||
opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias)
|
||||
opts.AliasDescriptions = normalizeAliasDescriptions(opts.AliasDescriptions, opts.Aliases, opts.EnableDoctorAlias)
|
||||
for _, cmd := range autoDisabledCommands(opts) {
|
||||
if !isCommandDisabled(cmd, opts.DisabledCommands) {
|
||||
opts.DisabledCommands = append(opts.DisabledCommands, cmd)
|
||||
}
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func normalizeAliases(aliases map[string][]string, enableDoctorAlias bool) map[string][]string {
|
||||
normalized := make(map[string][]string, len(aliases)+1)
|
||||
for name, target := range aliases {
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
trimmedTarget := trimArgs(target)
|
||||
if trimmedName == "" || len(trimmedTarget) == 0 {
|
||||
continue
|
||||
}
|
||||
normalized[trimmedName] = trimmedTarget
|
||||
}
|
||||
|
||||
if enableDoctorAlias {
|
||||
if _, ok := normalized[CommandDoctor]; !ok {
|
||||
normalized[CommandDoctor] = []string{CommandConfig, ConfigSubcommandTest}
|
||||
}
|
||||
}
|
||||
|
||||
if len(normalized) == 0 {
|
||||
return nil
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeAliasDescriptions(descriptions map[string]string, aliases map[string][]string, enableDoctorAlias bool) map[string]string {
|
||||
normalized := make(map[string]string, len(descriptions)+1)
|
||||
for name, description := range descriptions {
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
trimmedDescription := strings.TrimSpace(description)
|
||||
if trimmedName == "" || trimmedDescription == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := aliases[trimmedName]; !ok {
|
||||
continue
|
||||
}
|
||||
normalized[trimmedName] = trimmedDescription
|
||||
}
|
||||
|
||||
if enableDoctorAlias {
|
||||
if _, ok := aliases[CommandDoctor]; ok {
|
||||
if _, defined := normalized[CommandDoctor]; !defined {
|
||||
normalized[CommandDoctor] = "Diagnostiquer la configuration locale."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(normalized) == 0 {
|
||||
return nil
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func parseArgs(args []string) (command string, commandArgs []string, showHelp bool) {
|
||||
args = trimArgs(args)
|
||||
if len(args) == 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
first := strings.TrimSpace(args[0])
|
||||
switch first {
|
||||
case "help", "-h", "--help":
|
||||
if len(args) > 1 {
|
||||
return strings.TrimSpace(args[1]), trimArgs(args[2:]), true
|
||||
}
|
||||
return "", nil, true
|
||||
}
|
||||
|
||||
command = first
|
||||
commandArgs = trimArgs(args[1:])
|
||||
if len(commandArgs) > 0 {
|
||||
last := strings.TrimSpace(commandArgs[len(commandArgs)-1])
|
||||
if last == "-h" || last == "--help" {
|
||||
return command, commandArgs[:len(commandArgs)-1], true
|
||||
}
|
||||
}
|
||||
|
||||
return command, commandArgs, false
|
||||
}
|
||||
|
||||
func extractGlobalDebugFlag(args []string) ([]string, bool) {
|
||||
args = trimArgs(args)
|
||||
if len(args) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(args))
|
||||
debugEnabled := false
|
||||
for _, arg := range args {
|
||||
if strings.TrimSpace(arg) == "--debug" {
|
||||
debugEnabled = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, arg)
|
||||
}
|
||||
|
||||
return filtered, debugEnabled
|
||||
}
|
||||
|
||||
func expandAliases(args []string, aliases map[string][]string) []string {
|
||||
args = trimArgs(args)
|
||||
if len(args) == 0 || len(aliases) == 0 {
|
||||
return args
|
||||
}
|
||||
|
||||
if args[0] == "help" && len(args) > 1 {
|
||||
expanded, ok := resolveAlias(aliases, args[1])
|
||||
if !ok {
|
||||
return args
|
||||
}
|
||||
|
||||
withHelp := make([]string, 0, 1+len(expanded)+len(args[2:]))
|
||||
withHelp = append(withHelp, "help")
|
||||
withHelp = append(withHelp, expanded...)
|
||||
withHelp = append(withHelp, args[2:]...)
|
||||
return withHelp
|
||||
}
|
||||
|
||||
expanded, ok := resolveAlias(aliases, args[0])
|
||||
if !ok {
|
||||
return args
|
||||
}
|
||||
|
||||
withCommand := make([]string, 0, len(expanded)+len(args[1:]))
|
||||
withCommand = append(withCommand, expanded...)
|
||||
withCommand = append(withCommand, args[1:]...)
|
||||
|
||||
if len(withCommand) == 0 {
|
||||
return args
|
||||
}
|
||||
|
||||
last := strings.TrimSpace(withCommand[len(withCommand)-1])
|
||||
if last != "-h" && last != "--help" {
|
||||
return withCommand
|
||||
}
|
||||
|
||||
helpArgs := make([]string, 0, len(withCommand))
|
||||
helpArgs = append(helpArgs, "help")
|
||||
helpArgs = append(helpArgs, withCommand[:len(withCommand)-1]...)
|
||||
return helpArgs
|
||||
}
|
||||
|
||||
func resolveAlias(aliases map[string][]string, command string) ([]string, bool) {
|
||||
target, ok := aliases[strings.TrimSpace(command)]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
expanded := trimArgs(target)
|
||||
if len(expanded) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return expanded, true
|
||||
}
|
||||
|
||||
func trimArgs(args []string) []string {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
trimmed := strings.TrimSpace(arg)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func runConfigCommand(ctx context.Context, opts Options, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf(
|
||||
"%w: %s requires one of: %s, %s, %s",
|
||||
ErrSubcommandRequired,
|
||||
CommandConfig,
|
||||
ConfigSubcommandShow,
|
||||
ConfigSubcommandTest,
|
||||
ConfigSubcommandDelete,
|
||||
)
|
||||
}
|
||||
|
||||
subcommand := strings.TrimSpace(args[0])
|
||||
subcommandArgs := args[1:]
|
||||
|
||||
handler, known := resolveConfigHandler(opts.Hooks, subcommand)
|
||||
if !known {
|
||||
if opts.Hooks.Config != nil {
|
||||
return opts.Hooks.Config(ctx, Invocation{
|
||||
Command: CommandConfig,
|
||||
Args: args,
|
||||
Stdin: opts.Stdin,
|
||||
Stdout: opts.Stdout,
|
||||
Stderr: opts.Stderr,
|
||||
})
|
||||
}
|
||||
return fmt.Errorf("%w: %s %s", ErrUnknownSubcommand, CommandConfig, subcommand)
|
||||
}
|
||||
|
||||
if handler == nil {
|
||||
if opts.Hooks.Config != nil {
|
||||
return opts.Hooks.Config(ctx, Invocation{
|
||||
Command: fmt.Sprintf("%s %s", CommandConfig, subcommand),
|
||||
Args: subcommandArgs,
|
||||
Stdin: opts.Stdin,
|
||||
Stdout: opts.Stdout,
|
||||
Stderr: opts.Stderr,
|
||||
})
|
||||
}
|
||||
return fmt.Errorf("%w: %s %s", ErrCommandNotConfigured, CommandConfig, subcommand)
|
||||
}
|
||||
|
||||
return handler(ctx, Invocation{
|
||||
Command: fmt.Sprintf("%s %s", CommandConfig, subcommand),
|
||||
Args: subcommandArgs,
|
||||
Stdin: opts.Stdin,
|
||||
Stdout: opts.Stdout,
|
||||
Stderr: opts.Stderr,
|
||||
})
|
||||
}
|
||||
|
||||
func resolveConfigHandler(hooks Hooks, subcommand string) (Handler, bool) {
|
||||
switch subcommand {
|
||||
case ConfigSubcommandShow:
|
||||
return hooks.ConfigShow, true
|
||||
case ConfigSubcommandTest:
|
||||
return hooks.ConfigTest, true
|
||||
case ConfigSubcommandDelete:
|
||||
return hooks.ConfigDelete, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveHandler(command string, hooks Hooks) (Handler, bool) {
|
||||
for _, def := range commands {
|
||||
if def.Name == command {
|
||||
return def.Handler(hooks), true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func printHelp(opts Options, command string, args ...[]string) error {
|
||||
var commandArgs []string
|
||||
if len(args) > 0 {
|
||||
commandArgs = args[0]
|
||||
}
|
||||
|
||||
if command == "" {
|
||||
return printGlobalHelp(opts)
|
||||
}
|
||||
|
||||
if isCommandDisabled(command, opts.DisabledCommands) {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
||||
}
|
||||
|
||||
if command == CommandConfig {
|
||||
return printConfigHelp(opts, commandArgs)
|
||||
}
|
||||
|
||||
for _, def := range commands {
|
||||
if def.Name != command {
|
||||
continue
|
||||
}
|
||||
_, err := fmt.Fprintf(
|
||||
opts.Stdout,
|
||||
"Usage:\n %s %s [args]\n\n%s\n",
|
||||
opts.BinaryName,
|
||||
def.Name,
|
||||
def.Description,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
||||
}
|
||||
|
||||
func printConfigHelp(opts Options, args []string) error {
|
||||
if len(args) == 0 {
|
||||
_, err := fmt.Fprintf(
|
||||
opts.Stdout,
|
||||
"Usage:\n %s config <subcommand> [args]\n\nSubcommandes:\n %-7s Afficher la configuration résolue et la provenance des valeurs.\n %-7s Vérifier la configuration et la connectivité.\n %-7s Supprimer un profil local (optionnel).\n",
|
||||
opts.BinaryName,
|
||||
ConfigSubcommandShow,
|
||||
ConfigSubcommandTest,
|
||||
ConfigSubcommandDelete,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case ConfigSubcommandShow:
|
||||
_, err := fmt.Fprintf(
|
||||
opts.Stdout,
|
||||
"Usage:\n %s config %s [args]\n\nAfficher la configuration résolue et l'origine des valeurs.\n",
|
||||
opts.BinaryName,
|
||||
ConfigSubcommandShow,
|
||||
)
|
||||
return err
|
||||
case ConfigSubcommandTest:
|
||||
_, err := fmt.Fprintf(
|
||||
opts.Stdout,
|
||||
"Usage:\n %s config %s [args]\n\nTester la configuration résolue et la connectivité associée.\n",
|
||||
opts.BinaryName,
|
||||
ConfigSubcommandTest,
|
||||
)
|
||||
return err
|
||||
case ConfigSubcommandDelete:
|
||||
_, err := fmt.Fprintf(
|
||||
opts.Stdout,
|
||||
"Usage:\n %s config %s [args]\n\nSupprimer un profil local de configuration.\n",
|
||||
opts.BinaryName,
|
||||
ConfigSubcommandDelete,
|
||||
)
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("%w: %s %s", ErrUnknownSubcommand, CommandConfig, args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func printGlobalHelp(opts Options) error {
|
||||
if strings.TrimSpace(opts.Description) != "" {
|
||||
if _, err := fmt.Fprintf(opts.Stdout, "%s\n\n", opts.Description); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(opts.Stdout, "Usage:\n %s <command> [args]\n\n", opts.BinaryName); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(opts.Stdout, "Commandes communes:"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, def := range commands {
|
||||
if isCommandDisabled(def.Name, opts.DisabledCommands) {
|
||||
continue
|
||||
}
|
||||
if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", def.Name, def.Description); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(opts.Aliases) > 0 {
|
||||
if _, err := fmt.Fprintln(opts.Stdout, "\nAlias:"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(opts.Aliases))
|
||||
for name := range opts.Aliases {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
target := strings.Join(opts.Aliases[name], " ")
|
||||
description := aliasDescription(opts.AliasDescriptions, name, target)
|
||||
if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", name, description); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintln(opts.Stdout, "\nOptions globales:"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(opts.Stdout, " --debug Active le debug des appels Bitwarden."); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help <command>\n", opts.BinaryName)
|
||||
return err
|
||||
}
|
||||
|
||||
func autoDisabledCommands(opts Options) []string {
|
||||
h := opts.Hooks
|
||||
var disabled []string
|
||||
if h.Setup == nil {
|
||||
disabled = append(disabled, CommandSetup)
|
||||
}
|
||||
if h.Login == nil {
|
||||
disabled = append(disabled, CommandLogin)
|
||||
}
|
||||
if h.MCP == nil {
|
||||
disabled = append(disabled, CommandMCP)
|
||||
}
|
||||
if h.Config == nil && h.ConfigShow == nil && h.ConfigTest == nil && h.ConfigDelete == nil {
|
||||
disabled = append(disabled, CommandConfig)
|
||||
}
|
||||
if h.Update == nil {
|
||||
disabled = append(disabled, CommandUpdate)
|
||||
}
|
||||
if h.Version == nil && strings.TrimSpace(opts.Version) == "" {
|
||||
disabled = append(disabled, CommandVersion)
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
|
||||
func isCommandDisabled(command string, disabled []string) bool {
|
||||
for _, d := range disabled {
|
||||
if d == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func aliasDescription(descriptions map[string]string, name, target string) string {
|
||||
description := strings.TrimSpace(descriptions[name])
|
||||
if description == "" {
|
||||
return fmt.Sprintf("Alias de %q.", target)
|
||||
}
|
||||
return fmt.Sprintf("%s (alias de %q).", description, target)
|
||||
}
|
||||
688
bootstrap/bootstrap_test.go
Normal file
688
bootstrap/bootstrap_test.go
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunRoutesSetupHook(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
var got Invocation
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Version: "v1.2.3",
|
||||
Args: []string{"setup", "--profile", "prod"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{
|
||||
Setup: func(_ context.Context, inv Invocation) error {
|
||||
got = inv
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
if got.Command != CommandSetup {
|
||||
t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup)
|
||||
}
|
||||
wantArgs := []string{"--profile", "prod"}
|
||||
if !slices.Equal(got.Args, wantArgs) {
|
||||
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRoutesLoginHook(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
var got Invocation
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Version: "v1.2.3",
|
||||
Args: []string{"login", "--force"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{
|
||||
Login: func(_ context.Context, inv Invocation) error {
|
||||
got = inv
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
if got.Command != CommandLogin {
|
||||
t.Fatalf("invocation command = %q, want %q", got.Command, CommandLogin)
|
||||
}
|
||||
wantArgs := []string{"--force"}
|
||||
if !slices.Equal(got.Args, wantArgs) {
|
||||
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsUnknownCommand(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"boom"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
if !errors.Is(err, ErrUnknownCommand) {
|
||||
t.Fatalf("Run error = %v, want ErrUnknownCommand", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsCommandNotConfigured(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"config"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigShow: noop},
|
||||
})
|
||||
if !errors.Is(err, ErrSubcommandRequired) {
|
||||
t.Fatalf("Run error = %v, want ErrSubcommandRequired", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "show, test, delete") {
|
||||
t.Fatalf("Run error = %q, want mention of show, test, delete", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsVersionByDefault(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Version: "v1.2.3",
|
||||
Args: []string{"version"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
if stdout.String() != "v1.2.3\n" {
|
||||
t.Fatalf("stdout = %q, want %q", stdout.String(), "v1.2.3\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVersionHookOverridesDefault(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"version"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{
|
||||
Version: func(_ context.Context, inv Invocation) error {
|
||||
_, err := inv.Stdout.Write([]byte("custom-version\n"))
|
||||
return err
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
if stdout.String() != "custom-version\n" {
|
||||
t.Fatalf("stdout = %q, want %q", stdout.String(), "custom-version\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVersionAutoHiddenWithoutHookOrVersionString(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"version"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
if !errors.Is(err, ErrUnknownCommand) {
|
||||
t.Fatalf("Run error = %v, want ErrUnknownCommand", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsGlobalHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Description: "Binaire MCP de test.",
|
||||
Version: "v1.2.3",
|
||||
Args: []string{"help"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
text := stdout.String()
|
||||
for _, snippet := range []string{
|
||||
"Usage:",
|
||||
"setup",
|
||||
"mcp",
|
||||
"config",
|
||||
"update",
|
||||
"version",
|
||||
} {
|
||||
if !strings.Contains(text, snippet) {
|
||||
t.Fatalf("help output missing %q: %s", snippet, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsGlobalHelpWhenNoArgs(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Description: "Binaire MCP de test.",
|
||||
Version: "v1.2.3",
|
||||
Args: []string{},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
text := stdout.String()
|
||||
for _, snippet := range []string{
|
||||
"Usage:",
|
||||
"setup",
|
||||
"mcp",
|
||||
"config",
|
||||
"update",
|
||||
"version",
|
||||
} {
|
||||
if !strings.Contains(text, snippet) {
|
||||
t.Fatalf("help output missing %q: %s", snippet, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsCommandHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"help", "update"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{Update: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
text := stdout.String()
|
||||
if !strings.Contains(text, "my-mcp update [args]") {
|
||||
t.Fatalf("command help output = %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "auto-update") {
|
||||
t.Fatalf("command help output missing update description: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsLoginCommandHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"help", "login"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{Login: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
text := stdout.String()
|
||||
if !strings.Contains(text, "my-mcp login [args]") {
|
||||
t.Fatalf("command help output = %q", text)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(text), "bitwarden") {
|
||||
t.Fatalf("command help output missing bitwarden description: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsConfigHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"help", "config"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigShow: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
text := stdout.String()
|
||||
for _, snippet := range []string{
|
||||
"my-mcp config <subcommand>",
|
||||
"show",
|
||||
"test",
|
||||
} {
|
||||
if !strings.Contains(text, snippet) {
|
||||
t.Fatalf("config help output missing %q: %q", snippet, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsConfigSubcommandHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"help", "config", "show"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigShow: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
text := stdout.String()
|
||||
if !strings.Contains(text, "my-mcp config show [args]") {
|
||||
t.Fatalf("config subcommand help output = %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRoutesConfigShowHook(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
var got Invocation
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"config", "show", "--profile", "prod"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{
|
||||
ConfigShow: func(_ context.Context, inv Invocation) error {
|
||||
got = inv
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
if got.Command != "config show" {
|
||||
t.Fatalf("invocation command = %q, want %q", got.Command, "config show")
|
||||
}
|
||||
wantArgs := []string{"--profile", "prod"}
|
||||
if !slices.Equal(got.Args, wantArgs) {
|
||||
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfigShowReturnsCommandNotConfigured(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"config", "show"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigTest: noop},
|
||||
})
|
||||
if !errors.Is(err, ErrCommandNotConfigured) {
|
||||
t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfigReturnsUnknownSubcommand(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"config", "sync"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigShow: noop},
|
||||
})
|
||||
if !errors.Is(err, ErrUnknownSubcommand) {
|
||||
t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRoutesAliasToConfigSubcommandHook(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
var got Invocation
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Aliases: map[string][]string{
|
||||
"doctor": {CommandConfig, ConfigSubcommandTest},
|
||||
},
|
||||
Args: []string{"doctor", "--profile", "work"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{
|
||||
ConfigTest: func(_ context.Context, inv Invocation) error {
|
||||
got = inv
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
if got.Command != "config test" {
|
||||
t.Fatalf("invocation command = %q, want %q", got.Command, "config test")
|
||||
}
|
||||
wantArgs := []string{"--profile", "work"}
|
||||
if !slices.Equal(got.Args, wantArgs) {
|
||||
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Aliases: map[string][]string{
|
||||
"doctor": {CommandConfig, ConfigSubcommandTest},
|
||||
},
|
||||
Args: []string{"help", "doctor"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigTest: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
text := stdout.String()
|
||||
if !strings.Contains(text, "my-mcp config test [args]") {
|
||||
t.Fatalf("command help output = %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Aliases: map[string][]string{
|
||||
"doctor": {CommandConfig, ConfigSubcommandTest},
|
||||
},
|
||||
Args: []string{"doctor", "--help"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigTest: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
text := stdout.String()
|
||||
if !strings.Contains(text, "my-mcp config test [args]") {
|
||||
t.Fatalf("command help output = %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsAliasesInGlobalHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Aliases: map[string][]string{
|
||||
"doctor": {CommandConfig, ConfigSubcommandTest},
|
||||
},
|
||||
AliasDescriptions: map[string]string{
|
||||
"doctor": "Diagnostiquer la configuration locale.",
|
||||
},
|
||||
Args: []string{"help"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
text := stdout.String()
|
||||
if !strings.Contains(text, "Alias:") {
|
||||
t.Fatalf("global help output missing alias section: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, `doctor Diagnostiquer la configuration locale. (alias de "config test").`) {
|
||||
t.Fatalf("global help output missing doctor alias details: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRoutesDoctorAliasWhenEnabled(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
var got Invocation
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
EnableDoctorAlias: true,
|
||||
Args: []string{"doctor", "--profile", "prod"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{
|
||||
ConfigTest: func(_ context.Context, inv Invocation) error {
|
||||
got = inv
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
if got.Command != "config test" {
|
||||
t.Fatalf("invocation command = %q, want %q", got.Command, "config test")
|
||||
}
|
||||
wantArgs := []string{"--profile", "prod"}
|
||||
if !slices.Equal(got.Args, wantArgs) {
|
||||
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsDoctorAliasInGlobalHelpWhenEnabled(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
EnableDoctorAlias: true,
|
||||
Args: []string{"help"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
text := stdout.String()
|
||||
if !strings.Contains(text, "Alias:") {
|
||||
t.Fatalf("global help output missing alias section: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, `doctor Diagnostiquer la configuration locale. (alias de "config test").`) {
|
||||
t.Fatalf("global help output missing default doctor alias details: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAcceptsGlobalDebugFlagAndRoutesCommand(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
var got Invocation
|
||||
|
||||
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "")
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"--debug", "setup", "--profile", "prod"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{
|
||||
Setup: func(_ context.Context, inv Invocation) error {
|
||||
got = inv
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
if got.Command != CommandSetup {
|
||||
t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup)
|
||||
}
|
||||
wantArgs := []string{"--profile", "prod"}
|
||||
if !slices.Equal(got.Args, wantArgs) {
|
||||
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
|
||||
}
|
||||
if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" {
|
||||
t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAcceptsGlobalDebugFlagAfterCommand(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
var got Invocation
|
||||
|
||||
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "")
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"setup", "--debug", "--profile", "prod"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{
|
||||
Setup: func(_ context.Context, inv Invocation) error {
|
||||
got = inv
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
if got.Command != CommandSetup {
|
||||
t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup)
|
||||
}
|
||||
wantArgs := []string{"--profile", "prod"}
|
||||
if !slices.Equal(got.Args, wantArgs) {
|
||||
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
|
||||
}
|
||||
if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" {
|
||||
t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledCommandReturnsUnknown(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"login"},
|
||||
DisabledCommands: []string{"login"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
|
||||
if !errors.Is(err, ErrUnknownCommand) {
|
||||
t.Fatalf("err = %v, want ErrUnknownCommand", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledCommandHiddenFromHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
err := printGlobalHelp(Options{
|
||||
BinaryName: "my-mcp",
|
||||
DisabledCommands: []string{"login", "setup"},
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("printGlobalHelp error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "login") {
|
||||
t.Fatalf("help should not contain disabled command %q, got:\n%s", "login", out)
|
||||
}
|
||||
if strings.Contains(out, "setup") {
|
||||
t.Fatalf("help should not contain disabled command %q, got:\n%s", "setup", out)
|
||||
}
|
||||
if !strings.Contains(out, "mcp") {
|
||||
t.Fatalf("help should contain enabled command %q, got:\n%s", "mcp", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledCommandHelpReturnsUnknown(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
err := printHelp(Options{
|
||||
BinaryName: "my-mcp",
|
||||
DisabledCommands: []string{"login"},
|
||||
Stdout: &stdout,
|
||||
}, "login")
|
||||
|
||||
if !errors.Is(err, ErrUnknownCommand) {
|
||||
t.Fatalf("err = %v, want ErrUnknownCommand", err)
|
||||
}
|
||||
}
|
||||
56
bootstrap/configtest.go
Normal file
56
bootstrap/configtest.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
// StandardConfigTestOptions configure le handler de config test standard.
|
||||
// Aucun champ n'est obligatoire — omettez ceux qui ne s'appliquent pas à l'application.
|
||||
type StandardConfigTestOptions struct {
|
||||
// ConfigCheck vérifie que le fichier de configuration est lisible.
|
||||
// Construire avec cli.NewConfigCheck(store).
|
||||
ConfigCheck fwcli.DoctorCheck
|
||||
|
||||
// OpenStore ouvre le secret store pour vérifier sa disponibilité.
|
||||
// Si fourni, un SecretStoreAvailabilityCheck est automatiquement inclus.
|
||||
OpenStore func() (secretstore.Store, error)
|
||||
|
||||
// ConnectivityCheck vérifie la connectivité applicative (IMAP, HTTP, etc.).
|
||||
ConnectivityCheck fwcli.DoctorCheck
|
||||
|
||||
// ExtraChecks contient des vérifications supplémentaires spécifiques à l'application.
|
||||
ExtraChecks []fwcli.DoctorCheck
|
||||
}
|
||||
|
||||
// StandardConfigTestHandler retourne un Handler pour la commande config test.
|
||||
// Il inclut : config (si fourni), secret store (si fourni), connectivité (si fournie),
|
||||
// checks supplémentaires. Le ManifestCheck est intentionnellement absent : le manifest
|
||||
// est un artefact de build, pas une contrainte runtime.
|
||||
func StandardConfigTestHandler(opts StandardConfigTestOptions) Handler {
|
||||
return func(ctx context.Context, inv Invocation) error {
|
||||
doctorOpts := fwcli.DoctorOptions{
|
||||
ConfigCheck: opts.ConfigCheck,
|
||||
ConnectivityCheck: opts.ConnectivityCheck,
|
||||
ExtraChecks: opts.ExtraChecks,
|
||||
}
|
||||
|
||||
if opts.OpenStore != nil {
|
||||
doctorOpts.SecretStoreCheck = fwcli.SecretStoreAvailabilityCheck(opts.OpenStore)
|
||||
}
|
||||
|
||||
report := fwcli.RunDoctor(ctx, doctorOpts)
|
||||
|
||||
if err := fwcli.RenderDoctorReport(inv.Stdout, report); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if report.HasFailures() {
|
||||
return fmt.Errorf("config checks failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
155
bootstrap/configtest_test.go
Normal file
155
bootstrap/configtest_test.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
func TestStandardConfigTestHandlerRendersChecks(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
ConfigCheck: func(context.Context) fwcli.DoctorResult {
|
||||
return fwcli.DoctorResult{Name: "config", Status: fwcli.DoctorStatusOK, Summary: "config ok"}
|
||||
},
|
||||
ConnectivityCheck: func(context.Context) fwcli.DoctorResult {
|
||||
return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusOK, Summary: "reachable"}
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "[OK] config") {
|
||||
t.Fatalf("stdout = %q, want [OK] config", out)
|
||||
}
|
||||
if !strings.Contains(out, "[OK] connectivity") {
|
||||
t.Fatalf("stdout = %q, want [OK] connectivity", out)
|
||||
}
|
||||
if strings.Contains(out, "manifest") {
|
||||
t.Fatalf("stdout should not contain manifest check, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerIncludesSecretStoreCheck(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
OpenStore: func() (secretstore.Store, error) {
|
||||
return secretstore.Open(secretstore.Options{
|
||||
BackendPolicy: secretstore.BackendEnvOnly,
|
||||
LookupEnv: func(string) (string, bool) { return "", false },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "secret-store") {
|
||||
t.Fatalf("stdout = %q, want secret-store check", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerReturnsErrorOnFailure(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
ConnectivityCheck: func(context.Context) fwcli.DoctorResult {
|
||||
return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusFail, Summary: "unreachable"}
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err == nil {
|
||||
t.Fatal("handler should return error when checks fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "config checks failed") {
|
||||
t.Fatalf("err = %q, want 'config checks failed'", err.Error())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[FAIL] connectivity") {
|
||||
t.Fatalf("stdout = %q, want [FAIL] connectivity", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerOmitsManifestCheck(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
ExtraChecks: []fwcli.DoctorCheck{
|
||||
func(context.Context) fwcli.DoctorResult {
|
||||
return fwcli.DoctorResult{Name: "custom", Status: fwcli.DoctorStatusOK, Summary: "ok"}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(stdout.String(), "manifest") {
|
||||
t.Fatalf("manifest check should not appear, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerRunsViaBootstrap(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
openCalled := false
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"config", "test"},
|
||||
Stdout: &stdout,
|
||||
Hooks: Hooks{
|
||||
ConfigTest: StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
OpenStore: func() (secretstore.Store, error) {
|
||||
openCalled = true
|
||||
return secretstore.Open(secretstore.Options{
|
||||
BackendPolicy: secretstore.BackendEnvOnly,
|
||||
LookupEnv: func(string) (string, bool) { return "", false },
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
if !openCalled {
|
||||
t.Fatal("OpenStore should have been called")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "secret-store") {
|
||||
t.Fatalf("stdout = %q, want secret-store in output", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerSecretStoreFailurePropagates(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
storeErr := errors.New("bitwarden unavailable")
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
OpenStore: func() (secretstore.Store, error) {
|
||||
return nil, storeErr
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err == nil {
|
||||
t.Fatal("handler should return error when store fails")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[FAIL] secret-store") {
|
||||
t.Fatalf("stdout = %q, want [FAIL] secret-store", stdout.String())
|
||||
}
|
||||
}
|
||||
34
bootstrap/login.go
Normal file
34
bootstrap/login.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
var loginBitwarden = secretstore.LoginBitwarden
|
||||
|
||||
// BitwardenLoginHandler retourne un Handler pour la commande login des MCPs
|
||||
// qui utilisent le backend Bitwarden. Il authentifie et déverrouille le vault,
|
||||
// persiste BW_SESSION, et confirme le résultat.
|
||||
//
|
||||
// N'utiliser que si le MCP déclare secret_store.backend_policy = "bitwarden-cli"
|
||||
// dans son manifest. Pour les backends env-only ou keyring, ne pas définir de
|
||||
// hook Login : la commande sera automatiquement masquée.
|
||||
func BitwardenLoginHandler(binaryName string) Handler {
|
||||
name := strings.TrimSpace(binaryName)
|
||||
return func(_ context.Context, inv Invocation) error {
|
||||
if _, err := loginBitwarden(secretstore.BitwardenLoginOptions{
|
||||
ServiceName: name,
|
||||
Stdin: inv.Stdin,
|
||||
Stdout: inv.Stdout,
|
||||
Stderr: inv.Stderr,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(inv.Stdout, "Session Bitwarden persistée pour %q.\n", name)
|
||||
return err
|
||||
}
|
||||
}
|
||||
103
bootstrap/login_test.go
Normal file
103
bootstrap/login_test.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
func withLoginBitwarden(t *testing.T, fn func(secretstore.BitwardenLoginOptions) (string, error)) {
|
||||
t.Helper()
|
||||
previous := loginBitwarden
|
||||
loginBitwarden = fn
|
||||
t.Cleanup(func() { loginBitwarden = previous })
|
||||
}
|
||||
|
||||
func TestBitwardenLoginHandlerPrintsConfirmation(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
withLoginBitwarden(t, func(opts secretstore.BitwardenLoginOptions) (string, error) {
|
||||
if opts.ServiceName != "my-mcp" {
|
||||
t.Fatalf("ServiceName = %q, want %q", opts.ServiceName, "my-mcp")
|
||||
}
|
||||
return "session-token", nil
|
||||
})
|
||||
|
||||
handler := BitwardenLoginHandler("my-mcp")
|
||||
err := handler(context.Background(), Invocation{
|
||||
Command: CommandLogin,
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"my-mcp"`) {
|
||||
t.Fatalf("stdout = %q, want mention of binary name", out)
|
||||
}
|
||||
if !strings.Contains(out, "persistée") {
|
||||
t.Fatalf("stdout = %q, want confirmation message", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenLoginHandlerPropagatesError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
loginErr := errors.New("vault locked")
|
||||
|
||||
withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) {
|
||||
return "", loginErr
|
||||
})
|
||||
|
||||
handler := BitwardenLoginHandler("my-mcp")
|
||||
err := handler(context.Background(), Invocation{
|
||||
Command: CommandLogin,
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if !errors.Is(err, loginErr) {
|
||||
t.Fatalf("err = %v, want %v", err, loginErr)
|
||||
}
|
||||
if stdout.Len() > 0 {
|
||||
t.Fatalf("stdout should be empty on error, got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsesBitwardenLoginHandlerWhenHookSet(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) {
|
||||
return "tok", nil
|
||||
})
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"login"},
|
||||
Stdout: &stdout,
|
||||
Hooks: Hooks{
|
||||
Login: BitwardenLoginHandler("my-mcp"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "persistée") {
|
||||
t.Fatalf("stdout = %q, want confirmation", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLoginAutoHiddenWithoutHook(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"login"},
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if !errors.Is(err, ErrUnknownCommand) {
|
||||
t.Fatalf("err = %v, want ErrUnknownCommand", err)
|
||||
}
|
||||
}
|
||||
546
cli/doctor.go
Normal file
546
cli/doctor.go
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/config"
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type DoctorStatus string
|
||||
|
||||
const (
|
||||
DoctorStatusOK DoctorStatus = "ok"
|
||||
DoctorStatusWarn DoctorStatus = "warn"
|
||||
DoctorStatusFail DoctorStatus = "fail"
|
||||
)
|
||||
|
||||
type DoctorResult struct {
|
||||
Name string
|
||||
Status DoctorStatus
|
||||
Summary string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type DoctorCheck func(context.Context) DoctorResult
|
||||
|
||||
type DoctorReport struct {
|
||||
Results []DoctorResult
|
||||
}
|
||||
|
||||
type DoctorSummary struct {
|
||||
OK int
|
||||
Warn int
|
||||
Fail int
|
||||
Total int
|
||||
}
|
||||
|
||||
type DoctorSecret struct {
|
||||
Name string
|
||||
Label string
|
||||
}
|
||||
|
||||
type DoctorManifestValidator func(manifest.File, string) []string
|
||||
|
||||
type DoctorOptions struct {
|
||||
ConfigCheck DoctorCheck
|
||||
SecretStoreCheck DoctorCheck
|
||||
SecretBackendPolicy secretstore.BackendPolicy
|
||||
RequiredSecrets []DoctorSecret
|
||||
SecretStoreFactory func() (secretstore.Store, error)
|
||||
ManifestDir string
|
||||
ManifestValidator DoctorManifestValidator
|
||||
ManifestCheck DoctorCheck
|
||||
ConnectivityCheck DoctorCheck
|
||||
BitwardenOptions BitwardenDoctorOptions
|
||||
DisableAutoBitwardenCheck bool
|
||||
ExtraChecks []DoctorCheck
|
||||
}
|
||||
|
||||
type BitwardenDoctorOptions struct {
|
||||
Command string
|
||||
Debug bool
|
||||
Shell string
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
var checkBitwardenReady = secretstore.EnsureBitwardenReady
|
||||
|
||||
func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
|
||||
checks := make([]DoctorCheck, 0, 5+len(options.ExtraChecks))
|
||||
|
||||
if options.ConfigCheck != nil {
|
||||
checks = append(checks, options.ConfigCheck)
|
||||
}
|
||||
if options.SecretStoreCheck != nil {
|
||||
checks = append(checks, options.SecretStoreCheck)
|
||||
}
|
||||
if len(options.RequiredSecrets) > 0 && options.SecretStoreFactory != nil {
|
||||
checks = append(checks, RequiredSecretsCheck(options.SecretStoreFactory, options.RequiredSecrets))
|
||||
}
|
||||
if options.ManifestCheck != nil {
|
||||
checks = append(checks, options.ManifestCheck)
|
||||
} else if strings.TrimSpace(options.ManifestDir) != "" {
|
||||
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
|
||||
}
|
||||
if options.ConnectivityCheck != nil {
|
||||
checks = append(checks, options.ConnectivityCheck)
|
||||
}
|
||||
if shouldAutoIncludeBitwardenCheck(options) {
|
||||
checks = append(checks, BitwardenReadyCheck(options.BitwardenOptions))
|
||||
}
|
||||
checks = append(checks, options.ExtraChecks...)
|
||||
|
||||
results := make([]DoctorResult, 0, len(checks))
|
||||
for _, check := range checks {
|
||||
if check == nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, normalizeDoctorResult(check(ctx)))
|
||||
}
|
||||
|
||||
return DoctorReport{Results: results}
|
||||
}
|
||||
|
||||
func (r DoctorReport) Summary() DoctorSummary {
|
||||
summary := DoctorSummary{Total: len(r.Results)}
|
||||
for _, result := range r.Results {
|
||||
switch result.Status {
|
||||
case DoctorStatusOK:
|
||||
summary.OK++
|
||||
case DoctorStatusWarn:
|
||||
summary.Warn++
|
||||
case DoctorStatusFail:
|
||||
summary.Fail++
|
||||
default:
|
||||
summary.Fail++
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func (r DoctorReport) HasFailures() bool {
|
||||
return r.Summary().Fail > 0
|
||||
}
|
||||
|
||||
func RenderDoctorReport(w io.Writer, report DoctorReport) error {
|
||||
for _, result := range report.Results {
|
||||
if _, err := fmt.Fprintf(w, "[%s] %s: %s\n", doctorLabel(result.Status), result.Name, result.Summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if detail := strings.TrimSpace(result.Detail); detail != "" {
|
||||
if _, err := fmt.Fprintf(w, " %s\n", detail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary := report.Summary()
|
||||
_, err := fmt.Fprintf(
|
||||
w,
|
||||
"Summary: %d ok, %d warning(s), %d failure(s), %d total\n",
|
||||
summary.OK,
|
||||
summary.Warn,
|
||||
summary.Fail,
|
||||
summary.Total,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func NewConfigCheck[T any](store config.Store[T]) DoctorCheck {
|
||||
return func(context.Context) DoctorResult {
|
||||
path, err := store.ConfigPath()
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "config",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "cannot resolve config path",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
switch {
|
||||
case err == nil && info.IsDir():
|
||||
return DoctorResult{
|
||||
Name: "config",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "config path is a directory",
|
||||
Detail: path,
|
||||
}
|
||||
case err == nil:
|
||||
if _, err := store.Load(path); err != nil {
|
||||
return DoctorResult{
|
||||
Name: "config",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "config file is unreadable or invalid",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
return DoctorResult{
|
||||
Name: "config",
|
||||
Status: DoctorStatusOK,
|
||||
Summary: "config file is readable",
|
||||
Detail: path,
|
||||
}
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return DoctorResult{
|
||||
Name: "config",
|
||||
Status: DoctorStatusWarn,
|
||||
Summary: "config file is missing",
|
||||
Detail: path,
|
||||
}
|
||||
default:
|
||||
return DoctorResult{
|
||||
Name: "config",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "cannot access config file",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SecretStoreAvailabilityCheck(factory func() (secretstore.Store, error)) DoctorCheck {
|
||||
return func(context.Context) DoctorResult {
|
||||
if factory == nil {
|
||||
return DoctorResult{
|
||||
Name: "secret-store",
|
||||
Status: DoctorStatusWarn,
|
||||
Summary: "secret store check is not configured",
|
||||
}
|
||||
}
|
||||
|
||||
_, err := factory()
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "secret-store",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "secret backend is unavailable",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorResult{
|
||||
Name: "secret-store",
|
||||
Status: DoctorStatusOK,
|
||||
Summary: "secret backend is available",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck {
|
||||
return func(context.Context) DoctorResult {
|
||||
err := checkBitwardenReady(secretstore.Options{
|
||||
BitwardenCommand: strings.TrimSpace(options.Command),
|
||||
BitwardenDebug: options.Debug,
|
||||
Shell: strings.TrimSpace(options.Shell),
|
||||
LookupEnv: options.LookupEnv,
|
||||
})
|
||||
if err == nil {
|
||||
return DoctorResult{
|
||||
Name: "bitwarden",
|
||||
Status: DoctorStatusOK,
|
||||
Summary: "bitwarden CLI is ready",
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, secretstore.ErrBWNotLoggedIn):
|
||||
return DoctorResult{
|
||||
Name: "bitwarden",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "bitwarden login is required",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
case errors.Is(err, secretstore.ErrBWLocked):
|
||||
return DoctorResult{
|
||||
Name: "bitwarden",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "bitwarden vault is locked or BW_SESSION is missing",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
case errors.Is(err, secretstore.ErrBWUnavailable):
|
||||
return DoctorResult{
|
||||
Name: "bitwarden",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "bitwarden CLI is unavailable",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
default:
|
||||
return DoctorResult{
|
||||
Name: "bitwarden",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "bitwarden readiness check failed",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldAutoIncludeBitwardenCheck(options DoctorOptions) bool {
|
||||
if options.DisableAutoBitwardenCheck {
|
||||
return false
|
||||
}
|
||||
|
||||
if options.SecretBackendPolicy == secretstore.BackendBitwardenCLI {
|
||||
return true
|
||||
}
|
||||
|
||||
if options.SecretStoreFactory == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
store, err := options.SecretStoreFactory()
|
||||
if err == nil {
|
||||
return secretstore.EffectiveBackendPolicy(store) == secretstore.BackendBitwardenCLI
|
||||
}
|
||||
|
||||
if errors.Is(err, secretstore.ErrBWNotLoggedIn) ||
|
||||
errors.Is(err, secretstore.ErrBWLocked) ||
|
||||
errors.Is(err, secretstore.ErrBWUnavailable) {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.Contains(strings.ToLower(strings.TrimSpace(err.Error())), "bitwarden")
|
||||
}
|
||||
|
||||
func RequiredSecretsCheck(factory func() (secretstore.Store, error), required []DoctorSecret) DoctorCheck {
|
||||
return func(context.Context) DoctorResult {
|
||||
store, err := factory()
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "required-secrets",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "cannot inspect required secrets",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, secret := range required {
|
||||
name := strings.TrimSpace(secret.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := store.GetSecret(name)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, secretstore.ErrNotFound):
|
||||
if label := strings.TrimSpace(secret.Label); label != "" {
|
||||
missing = append(missing, fmt.Sprintf("%s (%s)", name, label))
|
||||
} else {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
default:
|
||||
return DoctorResult{
|
||||
Name: "required-secrets",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: fmt.Sprintf("cannot read secret %q", name),
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return DoctorResult{
|
||||
Name: "required-secrets",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "required secrets are missing",
|
||||
Detail: strings.Join(missing, ", "),
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorResult{
|
||||
Name: "required-secrets",
|
||||
Status: DoctorStatusOK,
|
||||
Summary: "all required secrets are present",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RequiredResolvedFieldsCheck(options ResolveOptions) DoctorCheck {
|
||||
required := requiredFieldNames(options.Fields)
|
||||
|
||||
return func(context.Context) DoctorResult {
|
||||
if len(required) == 0 {
|
||||
return DoctorResult{
|
||||
Name: "required-profile-fields",
|
||||
Status: DoctorStatusWarn,
|
||||
Summary: "no required profile field is configured",
|
||||
}
|
||||
}
|
||||
|
||||
resolution, err := ResolveFields(options)
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "required-profile-fields",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: resolveFieldsErrorSummary(err),
|
||||
Detail: FormatResolveFieldsError(err),
|
||||
}
|
||||
}
|
||||
|
||||
sources := make([]string, 0, len(required))
|
||||
for _, name := range required {
|
||||
field, ok := resolution.Get(name)
|
||||
if !ok || !field.Found {
|
||||
return DoctorResult{
|
||||
Name: "required-profile-fields",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "required profile values are missing",
|
||||
Detail: name,
|
||||
}
|
||||
}
|
||||
sources = append(sources, fmt.Sprintf("%s=%s", name, field.Source))
|
||||
}
|
||||
|
||||
return DoctorResult{
|
||||
Name: "required-profile-fields",
|
||||
Status: DoctorStatusOK,
|
||||
Summary: "required profile values are resolved",
|
||||
Detail: strings.Join(sources, ", "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FormatResolveFieldsError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var missing *MissingRequiredValuesError
|
||||
if errors.As(err, &missing) {
|
||||
if len(missing.Fields) == 0 {
|
||||
return "missing required configuration values"
|
||||
}
|
||||
return strings.Join(missing.Fields, ", ")
|
||||
}
|
||||
|
||||
var lookupErr *SourceLookupError
|
||||
if errors.As(err, &lookupErr) {
|
||||
key := strings.TrimSpace(lookupErr.Key)
|
||||
if key == "" {
|
||||
key = lookupErr.Field
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"field %q via source %q (key %q): %v",
|
||||
lookupErr.Field,
|
||||
lookupErr.Source,
|
||||
key,
|
||||
lookupErr.Err,
|
||||
)
|
||||
}
|
||||
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func ManifestCheck(startDir string, validator DoctorManifestValidator) DoctorCheck {
|
||||
return func(context.Context) DoctorResult {
|
||||
file, path, err := manifest.LoadDefault(startDir)
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "manifest",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "manifest is missing or invalid",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
if validator != nil {
|
||||
issues := validator(file, path)
|
||||
if len(issues) > 0 {
|
||||
return DoctorResult{
|
||||
Name: "manifest",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "manifest validation failed",
|
||||
Detail: strings.Join(issues, "; "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorResult{
|
||||
Name: "manifest",
|
||||
Status: DoctorStatusOK,
|
||||
Summary: "manifest is valid",
|
||||
Detail: path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDoctorResult(result DoctorResult) DoctorResult {
|
||||
result.Name = strings.TrimSpace(result.Name)
|
||||
if result.Name == "" {
|
||||
result.Name = "unnamed-check"
|
||||
}
|
||||
|
||||
result.Summary = strings.TrimSpace(result.Summary)
|
||||
if result.Summary == "" {
|
||||
result.Summary = "no details provided"
|
||||
}
|
||||
|
||||
result.Detail = strings.TrimSpace(result.Detail)
|
||||
switch result.Status {
|
||||
case DoctorStatusOK, DoctorStatusWarn, DoctorStatusFail:
|
||||
default:
|
||||
result.Status = DoctorStatusFail
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func doctorLabel(status DoctorStatus) string {
|
||||
switch status {
|
||||
case DoctorStatusOK:
|
||||
return "OK"
|
||||
case DoctorStatusWarn:
|
||||
return "WARN"
|
||||
default:
|
||||
return "FAIL"
|
||||
}
|
||||
}
|
||||
|
||||
func requiredFieldNames(specs []FieldSpec) []string {
|
||||
required := make([]string, 0, len(specs))
|
||||
seen := make(map[string]struct{}, len(specs))
|
||||
|
||||
for _, spec := range specs {
|
||||
if !spec.Required {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(spec.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[name] = struct{}{}
|
||||
required = append(required, name)
|
||||
}
|
||||
|
||||
return required
|
||||
}
|
||||
|
||||
func resolveFieldsErrorSummary(err error) string {
|
||||
var missing *MissingRequiredValuesError
|
||||
if errors.As(err, &missing) {
|
||||
return "required profile values are missing"
|
||||
}
|
||||
|
||||
var lookupErr *SourceLookupError
|
||||
if errors.As(err, &lookupErr) {
|
||||
return fmt.Sprintf("cannot resolve profile value %q", lookupErr.Field)
|
||||
}
|
||||
|
||||
return "profile resolution failed"
|
||||
}
|
||||
466
cli/doctor_test.go
Normal file
466
cli/doctor_test.go
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/config"
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type doctorProfile struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
|
||||
func TestRunDoctorAggregatesCommonChecksAndExtras(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempHome)
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
store := config.NewStore[doctorProfile]("doctor-test")
|
||||
path, err := store.ConfigPath()
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigPath returned error: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.FileConfig[doctorProfile]{
|
||||
Version: config.CurrentVersion,
|
||||
CurrentProfile: "default",
|
||||
Profiles: map[string]doctorProfile{
|
||||
"default": {BaseURL: "https://api.example.com"},
|
||||
},
|
||||
}
|
||||
if err := store.Save(path, cfg); err != nil {
|
||||
t.Fatalf("Save returned error: %v", err)
|
||||
}
|
||||
|
||||
manifestDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
secretFactory := func() (secretstore.Store, error) {
|
||||
return secretstore.Open(secretstore.Options{
|
||||
BackendPolicy: secretstore.BackendEnvOnly,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
if name == "api-token" {
|
||||
return "secret", true
|
||||
}
|
||||
return "", false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
report := RunDoctor(context.Background(), DoctorOptions{
|
||||
ConfigCheck: NewConfigCheck(store),
|
||||
SecretStoreCheck: SecretStoreAvailabilityCheck(secretFactory),
|
||||
RequiredSecrets: []DoctorSecret{
|
||||
{Name: "api-token", Label: "API token"},
|
||||
},
|
||||
SecretStoreFactory: secretFactory,
|
||||
ManifestDir: manifestDir,
|
||||
ConnectivityCheck: func(context.Context) DoctorResult {
|
||||
return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "service is reachable"}
|
||||
},
|
||||
ExtraChecks: []DoctorCheck{
|
||||
func(context.Context) DoctorResult {
|
||||
return DoctorResult{Name: "custom", Status: DoctorStatusWarn, Summary: "non blocking drift"}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
summary := report.Summary()
|
||||
if summary.OK != 5 || summary.Warn != 1 || summary.Fail != 0 || summary.Total != 6 {
|
||||
t.Fatalf("summary = %#v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConfigCheckWarnsWhenConfigIsMissing(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempHome)
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
store := config.NewStore[doctorProfile]("doctor-test")
|
||||
result := NewConfigCheck(store)(context.Background())
|
||||
|
||||
if result.Status != DoctorStatusWarn {
|
||||
t.Fatalf("status = %q, want warn", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "config.json") {
|
||||
t.Fatalf("detail = %q, want config path", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredSecretsCheckFailsWhenSecretIsMissing(t *testing.T) {
|
||||
check := RequiredSecretsCheck(func() (secretstore.Store, error) {
|
||||
return secretstore.Open(secretstore.Options{
|
||||
BackendPolicy: secretstore.BackendEnvOnly,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
return "", false
|
||||
},
|
||||
})
|
||||
}, []DoctorSecret{{Name: "API_TOKEN", Label: "API token"}})
|
||||
|
||||
result := check(context.Background())
|
||||
if result.Status != DoctorStatusFail {
|
||||
t.Fatalf("status = %q, want fail", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "API_TOKEN") {
|
||||
t.Fatalf("detail = %q, want secret name", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestCheckUsesValidator(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "mcp.toml"), []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
result := ManifestCheck(dir, func(_ manifest.File, path string) []string {
|
||||
_ = path
|
||||
return []string{"missing business section"}
|
||||
})(context.Background())
|
||||
|
||||
if result.Status != DoctorStatusFail {
|
||||
t.Fatalf("status = %q, want fail", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorUsesCustomManifestCheckWhenProvided(t *testing.T) {
|
||||
report := RunDoctor(context.Background(), DoctorOptions{
|
||||
ManifestDir: t.TempDir(),
|
||||
ManifestCheck: func(context.Context) DoctorResult {
|
||||
return DoctorResult{
|
||||
Name: "manifest",
|
||||
Status: DoctorStatusOK,
|
||||
Summary: "manifest is embedded",
|
||||
Detail: "embedded:mcp.toml",
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if len(report.Results) != 1 {
|
||||
t.Fatalf("result count = %d, want 1", len(report.Results))
|
||||
}
|
||||
result := report.Results[0]
|
||||
if result.Summary != "manifest is embedded" {
|
||||
t.Fatalf("summary = %q", result.Summary)
|
||||
}
|
||||
if result.Detail != "embedded:mcp.toml" {
|
||||
t.Fatalf("detail = %q", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDoctorReportFormatsStatusesAndSummary(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
report := DoctorReport{
|
||||
Results: []DoctorResult{
|
||||
{Name: "config", Status: DoctorStatusOK, Summary: "config file is readable", Detail: "/tmp/config.json"},
|
||||
{Name: "custom", Status: DoctorStatusWarn, Summary: "optional check skipped"},
|
||||
{Name: "manifest", Status: DoctorStatusFail, Summary: "manifest is invalid", Detail: "parse error"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := RenderDoctorReport(&out, report); err != nil {
|
||||
t.Fatalf("RenderDoctorReport returned error: %v", err)
|
||||
}
|
||||
|
||||
text := out.String()
|
||||
for _, needle := range []string{
|
||||
"[OK] config: config file is readable",
|
||||
"[WARN] custom: optional check skipped",
|
||||
"[FAIL] manifest: manifest is invalid",
|
||||
"Summary: 1 ok, 1 warning(s), 1 failure(s), 3 total",
|
||||
} {
|
||||
if !strings.Contains(text, needle) {
|
||||
t.Fatalf("output = %q, want substring %q", text, needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) {
|
||||
result := SecretStoreAvailabilityCheck(func() (secretstore.Store, error) {
|
||||
return nil, errors.New("backend missing")
|
||||
})(context.Background())
|
||||
|
||||
if result.Status != DoctorStatusFail {
|
||||
t.Fatalf("status = %q, want fail", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "backend missing") {
|
||||
t.Fatalf("detail = %q", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorOmitsManifestCheckWhenDirNotSet(t *testing.T) {
|
||||
report := RunDoctor(context.Background(), DoctorOptions{
|
||||
ConnectivityCheck: func(context.Context) DoctorResult {
|
||||
return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "ok"}
|
||||
},
|
||||
// ManifestDir intentionally empty, ManifestCheck intentionally nil.
|
||||
})
|
||||
|
||||
for _, r := range report.Results {
|
||||
if r.Name == "manifest" {
|
||||
t.Fatalf("manifest check should not be included when ManifestDir is empty, got: %+v", r)
|
||||
}
|
||||
}
|
||||
if len(report.Results) != 1 {
|
||||
t.Fatalf("result count = %d, want 1 (connectivity only)", len(report.Results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) {
|
||||
prev := checkBitwardenReady
|
||||
t.Cleanup(func() {
|
||||
checkBitwardenReady = prev
|
||||
})
|
||||
|
||||
t.Run("not logged in", func(t *testing.T) {
|
||||
checkBitwardenReady = func(options secretstore.Options) error {
|
||||
return fmt.Errorf("%w: run `bw login`", secretstore.ErrBWNotLoggedIn)
|
||||
}
|
||||
|
||||
result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background())
|
||||
if result.Status != DoctorStatusFail {
|
||||
t.Fatalf("status = %q, want fail", result.Status)
|
||||
}
|
||||
if result.Summary != "bitwarden login is required" {
|
||||
t.Fatalf("summary = %q", result.Summary)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "bw login") {
|
||||
t.Fatalf("detail = %q, want login remediation", result.Detail)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("locked", func(t *testing.T) {
|
||||
checkBitwardenReady = func(options secretstore.Options) error {
|
||||
return fmt.Errorf("%w: run `bw unlock --raw`", secretstore.ErrBWLocked)
|
||||
}
|
||||
|
||||
result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background())
|
||||
if result.Status != DoctorStatusFail {
|
||||
t.Fatalf("status = %q, want fail", result.Status)
|
||||
}
|
||||
if result.Summary != "bitwarden vault is locked or BW_SESSION is missing" {
|
||||
t.Fatalf("summary = %q", result.Summary)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "bw unlock --raw") {
|
||||
t.Fatalf("detail = %q, want unlock remediation", result.Detail)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ready", func(t *testing.T) {
|
||||
checkBitwardenReady = func(options secretstore.Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background())
|
||||
if result.Status != DoctorStatusOK {
|
||||
t.Fatalf("status = %q, want ok", result.Status)
|
||||
}
|
||||
if result.Summary != "bitwarden CLI is ready" {
|
||||
t.Fatalf("summary = %q", result.Summary)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunDoctorAutoInjectsBitwardenCheckForBitwardenPolicy(t *testing.T) {
|
||||
prev := checkBitwardenReady
|
||||
t.Cleanup(func() {
|
||||
checkBitwardenReady = prev
|
||||
})
|
||||
|
||||
lookupCalled := false
|
||||
checkBitwardenReady = func(options secretstore.Options) error {
|
||||
if options.Shell != "fish" {
|
||||
t.Fatalf("Shell = %q, want fish", options.Shell)
|
||||
}
|
||||
if options.BitwardenCommand != "bw" {
|
||||
t.Fatalf("BitwardenCommand = %q, want bw", options.BitwardenCommand)
|
||||
}
|
||||
if options.LookupEnv == nil {
|
||||
t.Fatal("LookupEnv should be forwarded")
|
||||
}
|
||||
_, _ = options.LookupEnv("BW_SESSION")
|
||||
return fmt.Errorf("%w: run unlock", secretstore.ErrBWLocked)
|
||||
}
|
||||
|
||||
report := RunDoctor(context.Background(), DoctorOptions{
|
||||
SecretBackendPolicy: secretstore.BackendBitwardenCLI,
|
||||
BitwardenOptions: BitwardenDoctorOptions{
|
||||
Command: "bw",
|
||||
Shell: "fish",
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
lookupCalled = true
|
||||
return "", false
|
||||
},
|
||||
},
|
||||
ManifestCheck: func(context.Context) DoctorResult {
|
||||
return DoctorResult{Name: "manifest", Status: DoctorStatusOK, Summary: "manifest ok"}
|
||||
},
|
||||
})
|
||||
|
||||
if !lookupCalled {
|
||||
t.Fatal("LookupEnv should be used by auto bitwarden check")
|
||||
}
|
||||
|
||||
var found *DoctorResult
|
||||
for i := range report.Results {
|
||||
if report.Results[i].Name == "bitwarden" {
|
||||
found = &report.Results[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("report results = %#v, want auto bitwarden check", report.Results)
|
||||
}
|
||||
if found.Status != DoctorStatusFail {
|
||||
t.Fatalf("bitwarden status = %q, want fail", found.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorCanDisableAutoBitwardenCheck(t *testing.T) {
|
||||
prev := checkBitwardenReady
|
||||
t.Cleanup(func() {
|
||||
checkBitwardenReady = prev
|
||||
})
|
||||
|
||||
checkBitwardenReady = func(options secretstore.Options) error {
|
||||
t.Fatal("checkBitwardenReady should not be called when auto check is disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
report := RunDoctor(context.Background(), DoctorOptions{
|
||||
DisableAutoBitwardenCheck: true,
|
||||
SecretBackendPolicy: secretstore.BackendBitwardenCLI,
|
||||
ManifestCheck: func(context.Context) DoctorResult {
|
||||
return DoctorResult{Name: "manifest", Status: DoctorStatusOK, Summary: "manifest ok"}
|
||||
},
|
||||
})
|
||||
|
||||
if len(report.Results) != 1 {
|
||||
t.Fatalf("result count = %d, want 1", len(report.Results))
|
||||
}
|
||||
if report.Results[0].Name != "manifest" {
|
||||
t.Fatalf("result name = %q, want manifest", report.Results[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredResolvedFieldsCheckReportsSources(t *testing.T) {
|
||||
check := RequiredResolvedFieldsCheck(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
{Name: "base_url", Required: true, ConfigKey: "base_url"},
|
||||
{
|
||||
Name: "api_token",
|
||||
Required: true,
|
||||
EnvKey: "MY_API_TOKEN",
|
||||
SecretKey: "my-api-token",
|
||||
Sources: []ValueSource{SourceEnv, SourceSecret},
|
||||
},
|
||||
},
|
||||
Lookup: ResolveLookup(ResolveLookupOptions{
|
||||
Env: MapLookup(map[string]string{"MY_API_TOKEN": "env-token"}),
|
||||
Config: ConfigMap(map[string]string{"base_url": "https://api.example.com"}),
|
||||
Secret: MapLookup(map[string]string{"my-api-token": "secret-token"}),
|
||||
}),
|
||||
})
|
||||
|
||||
result := check(context.Background())
|
||||
if result.Status != DoctorStatusOK {
|
||||
t.Fatalf("status = %q, want ok", result.Status)
|
||||
}
|
||||
for _, needle := range []string{"base_url=config", "api_token=env"} {
|
||||
if !strings.Contains(result.Detail, needle) {
|
||||
t.Fatalf("detail = %q, want substring %q", result.Detail, needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredResolvedFieldsCheckUsesSecretAsFallback(t *testing.T) {
|
||||
check := RequiredResolvedFieldsCheck(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
{
|
||||
Name: "api_token",
|
||||
Required: true,
|
||||
EnvKey: "MY_API_TOKEN",
|
||||
SecretKey: "my-api-token",
|
||||
Sources: []ValueSource{SourceEnv, SourceSecret},
|
||||
},
|
||||
},
|
||||
Lookup: ResolveLookup(ResolveLookupOptions{
|
||||
Env: MapLookup(map[string]string{}),
|
||||
Secret: MapLookup(map[string]string{"my-api-token": "secret-token"}),
|
||||
}),
|
||||
})
|
||||
|
||||
result := check(context.Background())
|
||||
if result.Status != DoctorStatusOK {
|
||||
t.Fatalf("status = %q, want ok", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "api_token=secret") {
|
||||
t.Fatalf("detail = %q, want secret provenance", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredResolvedFieldsCheckFailsWithMissingRequiredField(t *testing.T) {
|
||||
check := RequiredResolvedFieldsCheck(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
{Name: "base_url", Required: true},
|
||||
},
|
||||
Lookup: ResolveLookup(ResolveLookupOptions{
|
||||
Env: MapLookup(map[string]string{}),
|
||||
}),
|
||||
})
|
||||
|
||||
result := check(context.Background())
|
||||
if result.Status != DoctorStatusFail {
|
||||
t.Fatalf("status = %q, want fail", result.Status)
|
||||
}
|
||||
if result.Summary != "required profile values are missing" {
|
||||
t.Fatalf("summary = %q", result.Summary)
|
||||
}
|
||||
if result.Detail != "base_url" {
|
||||
t.Fatalf("detail = %q, want base_url", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredResolvedFieldsCheckFormatsLookupErrors(t *testing.T) {
|
||||
lookupErr := errors.New("vault unavailable")
|
||||
check := RequiredResolvedFieldsCheck(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
{Name: "api_token", Required: true, Sources: []ValueSource{SourceSecret}},
|
||||
},
|
||||
Lookup: func(source ValueSource, key string) (string, bool, error) {
|
||||
if source == SourceSecret {
|
||||
return "", false, lookupErr
|
||||
}
|
||||
return "", false, nil
|
||||
},
|
||||
})
|
||||
|
||||
result := check(context.Background())
|
||||
if result.Status != DoctorStatusFail {
|
||||
t.Fatalf("status = %q, want fail", result.Status)
|
||||
}
|
||||
if result.Summary != "cannot resolve profile value \"api_token\"" {
|
||||
t.Fatalf("summary = %q", result.Summary)
|
||||
}
|
||||
for _, needle := range []string{"api_token", "source \"secret\"", "key \"api_token\"", "vault unavailable"} {
|
||||
if !strings.Contains(result.Detail, needle) {
|
||||
t.Fatalf("detail = %q, want substring %q", result.Detail, needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResolveFieldsErrorWithNilError(t *testing.T) {
|
||||
if got := FormatResolveFieldsError(nil); got != "" {
|
||||
t.Fatalf("FormatResolveFieldsError(nil) = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
283
cli/resolve.go
Normal file
283
cli/resolve.go
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ValueSource string
|
||||
|
||||
const (
|
||||
SourceFlag ValueSource = "flag"
|
||||
SourceEnv ValueSource = "env"
|
||||
SourceConfig ValueSource = "config"
|
||||
SourceSecret ValueSource = "secret"
|
||||
SourceDefault ValueSource = "default"
|
||||
)
|
||||
|
||||
var DefaultResolutionOrder = []ValueSource{
|
||||
SourceFlag,
|
||||
SourceEnv,
|
||||
SourceConfig,
|
||||
SourceSecret,
|
||||
}
|
||||
|
||||
var ErrInvalidResolverInput = errors.New("invalid resolver input")
|
||||
|
||||
type FieldSpec struct {
|
||||
Name string
|
||||
Required bool
|
||||
DefaultValue string
|
||||
Sources []ValueSource
|
||||
FlagKey string
|
||||
EnvKey string
|
||||
ConfigKey string
|
||||
SecretKey string
|
||||
}
|
||||
|
||||
type LookupFunc func(source ValueSource, key string) (string, bool, error)
|
||||
|
||||
type ResolveOptions struct {
|
||||
Fields []FieldSpec
|
||||
Order []ValueSource
|
||||
Lookup LookupFunc
|
||||
}
|
||||
|
||||
type ResolvedField struct {
|
||||
Name string
|
||||
Value string
|
||||
Source ValueSource
|
||||
Found bool
|
||||
}
|
||||
|
||||
type Resolution struct {
|
||||
Fields []ResolvedField
|
||||
}
|
||||
|
||||
func (r Resolution) Get(name string) (ResolvedField, bool) {
|
||||
needle := strings.TrimSpace(name)
|
||||
for _, field := range r.Fields {
|
||||
if field.Name == needle {
|
||||
return field, true
|
||||
}
|
||||
}
|
||||
return ResolvedField{}, false
|
||||
}
|
||||
|
||||
type MissingRequiredValuesError struct {
|
||||
Fields []string
|
||||
}
|
||||
|
||||
func (e *MissingRequiredValuesError) Error() string {
|
||||
return fmt.Sprintf("missing required configuration values: %s", strings.Join(e.Fields, ", "))
|
||||
}
|
||||
|
||||
type SourceLookupError struct {
|
||||
Field string
|
||||
Source ValueSource
|
||||
Key string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *SourceLookupError) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"resolve %q from %q (key %q): %v",
|
||||
e.Field,
|
||||
e.Source,
|
||||
e.Key,
|
||||
e.Err,
|
||||
)
|
||||
}
|
||||
|
||||
func (e *SourceLookupError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type StaticLookup struct {
|
||||
Flags map[string]string
|
||||
Env map[string]string
|
||||
Config map[string]string
|
||||
Secrets map[string]string
|
||||
}
|
||||
|
||||
func (l StaticLookup) Lookup(source ValueSource, key string) (string, bool, error) {
|
||||
var values map[string]string
|
||||
switch source {
|
||||
case SourceFlag:
|
||||
values = l.Flags
|
||||
case SourceEnv:
|
||||
values = l.Env
|
||||
case SourceConfig:
|
||||
values = l.Config
|
||||
case SourceSecret:
|
||||
values = l.Secrets
|
||||
case SourceDefault:
|
||||
return "", false, fmt.Errorf("%w: source %q is reserved", ErrInvalidResolverInput, source)
|
||||
default:
|
||||
return "", false, fmt.Errorf("%w: unknown source %q", ErrInvalidResolverInput, source)
|
||||
}
|
||||
|
||||
value, ok := values[key]
|
||||
return value, ok, nil
|
||||
}
|
||||
|
||||
func ResolveFields(options ResolveOptions) (Resolution, error) {
|
||||
if options.Lookup == nil {
|
||||
return Resolution{}, fmt.Errorf("%w: lookup is required", ErrInvalidResolverInput)
|
||||
}
|
||||
|
||||
globalOrder, err := normalizeOrder(options.Order, DefaultResolutionOrder)
|
||||
if err != nil {
|
||||
return Resolution{}, fmt.Errorf("%w: %v", ErrInvalidResolverInput, err)
|
||||
}
|
||||
|
||||
resolution := Resolution{
|
||||
Fields: make([]ResolvedField, 0, len(options.Fields)),
|
||||
}
|
||||
missingRequired := make([]string, 0)
|
||||
seenNames := make(map[string]struct{}, len(options.Fields))
|
||||
|
||||
for i, spec := range options.Fields {
|
||||
name := strings.TrimSpace(spec.Name)
|
||||
if name == "" {
|
||||
return Resolution{}, fmt.Errorf("%w: field at index %d has empty name", ErrInvalidResolverInput, i)
|
||||
}
|
||||
if _, exists := seenNames[name]; exists {
|
||||
return Resolution{}, fmt.Errorf("%w: duplicate field name %q", ErrInvalidResolverInput, name)
|
||||
}
|
||||
seenNames[name] = struct{}{}
|
||||
|
||||
order := globalOrder
|
||||
if len(spec.Sources) > 0 {
|
||||
order, err = normalizeOrder(spec.Sources, globalOrder)
|
||||
if err != nil {
|
||||
return Resolution{}, fmt.Errorf("%w: field %q: %v", ErrInvalidResolverInput, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
field := ResolvedField{Name: name}
|
||||
for _, source := range order {
|
||||
key := spec.keyFor(source)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
value, found, err := options.Lookup(source, key)
|
||||
if err != nil {
|
||||
return resolution, &SourceLookupError{
|
||||
Field: name,
|
||||
Source: source,
|
||||
Key: key,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if found && trimmed != "" {
|
||||
field.Value = trimmed
|
||||
field.Source = source
|
||||
field.Found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !field.Found {
|
||||
defaultValue := strings.TrimSpace(spec.DefaultValue)
|
||||
if defaultValue != "" {
|
||||
field.Value = defaultValue
|
||||
field.Source = SourceDefault
|
||||
field.Found = true
|
||||
}
|
||||
}
|
||||
|
||||
if spec.Required && !field.Found {
|
||||
missingRequired = append(missingRequired, name)
|
||||
}
|
||||
resolution.Fields = append(resolution.Fields, field)
|
||||
}
|
||||
|
||||
if len(missingRequired) > 0 {
|
||||
return resolution, &MissingRequiredValuesError{Fields: missingRequired}
|
||||
}
|
||||
|
||||
return resolution, nil
|
||||
}
|
||||
|
||||
func RenderResolutionProvenance(w io.Writer, resolution Resolution) error {
|
||||
for _, field := range resolution.Fields {
|
||||
source := "missing"
|
||||
if field.Found {
|
||||
source = string(field.Source)
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(w, "%s: %s\n", field.Name, source); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeOrder(input []ValueSource, fallback []ValueSource) ([]ValueSource, error) {
|
||||
order := input
|
||||
if len(order) == 0 {
|
||||
order = fallback
|
||||
}
|
||||
|
||||
result := make([]ValueSource, 0, len(order))
|
||||
seen := make(map[ValueSource]struct{}, len(order))
|
||||
for _, source := range order {
|
||||
if !isKnownSource(source) {
|
||||
return nil, fmt.Errorf("unknown source %q", source)
|
||||
}
|
||||
if source == SourceDefault {
|
||||
return nil, fmt.Errorf("source %q cannot be used in resolution order", source)
|
||||
}
|
||||
if _, exists := seen[source]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[source] = struct{}{}
|
||||
result = append(result, source)
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("resolution order is empty")
|
||||
}
|
||||
|
||||
return slices.Clone(result), nil
|
||||
}
|
||||
|
||||
func isKnownSource(source ValueSource) bool {
|
||||
switch source {
|
||||
case SourceFlag, SourceEnv, SourceConfig, SourceSecret, SourceDefault:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s FieldSpec) keyFor(source ValueSource) string {
|
||||
switch source {
|
||||
case SourceFlag:
|
||||
return fallbackKey(s.FlagKey, s.Name)
|
||||
case SourceEnv:
|
||||
return fallbackKey(s.EnvKey, s.Name)
|
||||
case SourceConfig:
|
||||
return fallbackKey(s.ConfigKey, s.Name)
|
||||
case SourceSecret:
|
||||
return fallbackKey(s.SecretKey, s.Name)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackKey(explicit, fallback string) string {
|
||||
if key := strings.TrimSpace(explicit); key != "" {
|
||||
return key
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
87
cli/resolve_lookup.go
Normal file
87
cli/resolve_lookup.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type KeyLookupFunc func(key string) (string, bool, error)
|
||||
|
||||
type ResolveLookupOptions struct {
|
||||
Flag KeyLookupFunc
|
||||
Env KeyLookupFunc
|
||||
Config KeyLookupFunc
|
||||
Secret KeyLookupFunc
|
||||
}
|
||||
|
||||
func ResolveLookup(options ResolveLookupOptions) LookupFunc {
|
||||
return func(source ValueSource, key string) (string, bool, error) {
|
||||
switch source {
|
||||
case SourceFlag:
|
||||
return runKeyLookup(options.Flag, key)
|
||||
case SourceEnv:
|
||||
return runKeyLookup(options.Env, key)
|
||||
case SourceConfig:
|
||||
return runKeyLookup(options.Config, key)
|
||||
case SourceSecret:
|
||||
return runKeyLookup(options.Secret, key)
|
||||
case SourceDefault:
|
||||
return "", false, nil
|
||||
default:
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runKeyLookup(lookup KeyLookupFunc, key string) (string, bool, error) {
|
||||
if lookup == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
if key == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
return lookup(key)
|
||||
}
|
||||
|
||||
func MapLookup(values map[string]string) KeyLookupFunc {
|
||||
return func(key string) (string, bool, error) {
|
||||
value, ok := values[key]
|
||||
return value, ok, nil
|
||||
}
|
||||
}
|
||||
|
||||
func EnvLookup(lookup func(string) (string, bool)) KeyLookupFunc {
|
||||
reader := lookup
|
||||
if reader == nil {
|
||||
reader = os.LookupEnv
|
||||
}
|
||||
|
||||
return func(key string) (string, bool, error) {
|
||||
value, ok := reader(key)
|
||||
return value, ok, nil
|
||||
}
|
||||
}
|
||||
|
||||
func ConfigMap(values map[string]string) KeyLookupFunc {
|
||||
return MapLookup(values)
|
||||
}
|
||||
|
||||
func SecretStore(store secretstore.Store) KeyLookupFunc {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return func(key string) (string, bool, error) {
|
||||
value, err := store.GetSecret(key)
|
||||
switch {
|
||||
case err == nil:
|
||||
return value, true, nil
|
||||
case errors.Is(err, secretstore.ErrNotFound):
|
||||
return "", false, nil
|
||||
default:
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
145
cli/resolve_lookup_test.go
Normal file
145
cli/resolve_lookup_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type testSecretStore struct {
|
||||
values map[string]string
|
||||
errs map[string]error
|
||||
}
|
||||
|
||||
func (s testSecretStore) SetSecret(name, label, secret string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s testSecretStore) GetSecret(name string) (string, error) {
|
||||
if err, ok := s.errs[name]; ok {
|
||||
return "", err
|
||||
}
|
||||
value, ok := s.values[name]
|
||||
if !ok {
|
||||
return "", secretstore.ErrNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s testSecretStore) DeleteSecret(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResolveLookupWithStandardProviders(t *testing.T) {
|
||||
t.Setenv("MCP_PASSWORD", "")
|
||||
|
||||
lookup := ResolveLookup(ResolveLookupOptions{
|
||||
Flag: MapLookup(map[string]string{"host": "https://flag.example.com"}),
|
||||
Env: EnvLookup(nil),
|
||||
Config: ConfigMap(map[string]string{"username": "config-user"}),
|
||||
Secret: SecretStore(testSecretStore{
|
||||
values: map[string]string{"smtp-password": "secret-password"},
|
||||
}),
|
||||
})
|
||||
|
||||
resolution, err := ResolveFields(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
{
|
||||
Name: "host",
|
||||
Required: true,
|
||||
FlagKey: "host",
|
||||
},
|
||||
{
|
||||
Name: "username",
|
||||
Required: true,
|
||||
EnvKey: "MCP_USERNAME",
|
||||
ConfigKey: "username",
|
||||
},
|
||||
{
|
||||
Name: "password",
|
||||
Required: true,
|
||||
EnvKey: "MCP_PASSWORD",
|
||||
SecretKey: "smtp-password",
|
||||
},
|
||||
},
|
||||
Lookup: lookup,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveFields returned error: %v", err)
|
||||
}
|
||||
|
||||
host, _ := resolution.Get("host")
|
||||
if host.Source != SourceFlag || host.Value != "https://flag.example.com" {
|
||||
t.Fatalf("host = %+v", host)
|
||||
}
|
||||
|
||||
username, _ := resolution.Get("username")
|
||||
if username.Source != SourceConfig || username.Value != "config-user" {
|
||||
t.Fatalf("username = %+v", username)
|
||||
}
|
||||
|
||||
password, _ := resolution.Get("password")
|
||||
if password.Source != SourceSecret || password.Value != "secret-password" {
|
||||
t.Fatalf("password = %+v", password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretStoreProviderTreatsErrNotFoundAsMissing(t *testing.T) {
|
||||
lookup := ResolveLookup(ResolveLookupOptions{
|
||||
Secret: SecretStore(testSecretStore{
|
||||
errs: map[string]error{"smtp-password": secretstore.ErrNotFound},
|
||||
}),
|
||||
})
|
||||
|
||||
resolution, err := ResolveFields(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
{
|
||||
Name: "password",
|
||||
Required: true,
|
||||
SecretKey: "smtp-password",
|
||||
DefaultValue: "fallback-password",
|
||||
},
|
||||
},
|
||||
Lookup: lookup,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveFields returned error: %v", err)
|
||||
}
|
||||
|
||||
password, _ := resolution.Get("password")
|
||||
if password.Source != SourceDefault || password.Value != "fallback-password" {
|
||||
t.Fatalf("password = %+v", password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretStoreProviderPropagatesBackendErrors(t *testing.T) {
|
||||
backendErr := errors.New("backend unavailable")
|
||||
lookup := ResolveLookup(ResolveLookupOptions{
|
||||
Secret: SecretStore(testSecretStore{
|
||||
errs: map[string]error{"smtp-password": backendErr},
|
||||
}),
|
||||
})
|
||||
|
||||
_, err := ResolveFields(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
{
|
||||
Name: "password",
|
||||
Required: true,
|
||||
SecretKey: "smtp-password",
|
||||
},
|
||||
},
|
||||
Lookup: lookup,
|
||||
})
|
||||
|
||||
var sourceErr *SourceLookupError
|
||||
if !errors.As(err, &sourceErr) {
|
||||
t.Fatalf("ResolveFields error = %v, want SourceLookupError", err)
|
||||
}
|
||||
if sourceErr.Source != SourceSecret {
|
||||
t.Fatalf("sourceErr.Source = %q, want %q", sourceErr.Source, SourceSecret)
|
||||
}
|
||||
if !errors.Is(err, backendErr) {
|
||||
t.Fatalf("ResolveFields error should wrap backend error")
|
||||
}
|
||||
}
|
||||
291
cli/resolve_test.go
Normal file
291
cli/resolve_test.go
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveFieldsUsesDefaultOrderAndTracksSource(t *testing.T) {
|
||||
resolution, err := ResolveFields(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
{
|
||||
Name: "base_url",
|
||||
Required: true,
|
||||
FlagKey: "base-url",
|
||||
},
|
||||
{
|
||||
Name: "api_token",
|
||||
Required: true,
|
||||
EnvKey: "API_TOKEN",
|
||||
SecretKey: "my-api-token",
|
||||
},
|
||||
{
|
||||
Name: "stream_id",
|
||||
},
|
||||
},
|
||||
Lookup: StaticLookup{
|
||||
Flags: map[string]string{
|
||||
"base-url": "https://flag.example.com",
|
||||
},
|
||||
Env: map[string]string{
|
||||
"base_url": "https://env.example.com",
|
||||
"API_TOKEN": "",
|
||||
},
|
||||
Config: map[string]string{
|
||||
"stream_id": "stream-from-config",
|
||||
},
|
||||
Secrets: map[string]string{
|
||||
"my-api-token": "secret-token",
|
||||
},
|
||||
}.Lookup,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveFields returned error: %v", err)
|
||||
}
|
||||
|
||||
baseURL, ok := resolution.Get("base_url")
|
||||
if !ok {
|
||||
t.Fatalf("base_url was not resolved")
|
||||
}
|
||||
if !baseURL.Found {
|
||||
t.Fatalf("base_url must be found")
|
||||
}
|
||||
if baseURL.Source != SourceFlag {
|
||||
t.Fatalf("base_url source = %q, want %q", baseURL.Source, SourceFlag)
|
||||
}
|
||||
if baseURL.Value != "https://flag.example.com" {
|
||||
t.Fatalf("base_url value = %q", baseURL.Value)
|
||||
}
|
||||
|
||||
apiToken, ok := resolution.Get("api_token")
|
||||
if !ok {
|
||||
t.Fatalf("api_token was not resolved")
|
||||
}
|
||||
if apiToken.Source != SourceSecret {
|
||||
t.Fatalf("api_token source = %q, want %q", apiToken.Source, SourceSecret)
|
||||
}
|
||||
if apiToken.Value != "secret-token" {
|
||||
t.Fatalf("api_token value = %q", apiToken.Value)
|
||||
}
|
||||
|
||||
streamID, ok := resolution.Get("stream_id")
|
||||
if !ok {
|
||||
t.Fatalf("stream_id was not resolved")
|
||||
}
|
||||
if streamID.Source != SourceConfig {
|
||||
t.Fatalf("stream_id source = %q, want %q", streamID.Source, SourceConfig)
|
||||
}
|
||||
if streamID.Value != "stream-from-config" {
|
||||
t.Fatalf("stream_id value = %q", streamID.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFieldsSupportsCustomGlobalOrder(t *testing.T) {
|
||||
resolution, err := ResolveFields(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
{
|
||||
Name: "base_url",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Order: []ValueSource{SourceConfig, SourceEnv, SourceFlag},
|
||||
Lookup: StaticLookup{
|
||||
Flags: map[string]string{
|
||||
"base_url": "https://flag.example.com",
|
||||
},
|
||||
Env: map[string]string{
|
||||
"base_url": "https://env.example.com",
|
||||
},
|
||||
Config: map[string]string{
|
||||
"base_url": "https://config.example.com",
|
||||
},
|
||||
}.Lookup,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveFields returned error: %v", err)
|
||||
}
|
||||
|
||||
baseURL, _ := resolution.Get("base_url")
|
||||
if baseURL.Source != SourceConfig {
|
||||
t.Fatalf("base_url source = %q, want %q", baseURL.Source, SourceConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFieldsSupportsPerFieldOrderAndDefaultValues(t *testing.T) {
|
||||
resolution, err := ResolveFields(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
{
|
||||
Name: "api_token",
|
||||
Required: true,
|
||||
Sources: []ValueSource{SourceSecret, SourceEnv},
|
||||
SecretKey: "API_TOKEN_SECRET",
|
||||
DefaultValue: "default-token",
|
||||
},
|
||||
{
|
||||
Name: "region",
|
||||
DefaultValue: "eu-west",
|
||||
},
|
||||
},
|
||||
Lookup: StaticLookup{
|
||||
Env: map[string]string{
|
||||
"api_token": "env-token",
|
||||
},
|
||||
Secrets: map[string]string{
|
||||
"API_TOKEN_SECRET": "secret-token",
|
||||
},
|
||||
}.Lookup,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveFields returned error: %v", err)
|
||||
}
|
||||
|
||||
apiToken, _ := resolution.Get("api_token")
|
||||
if apiToken.Source != SourceSecret {
|
||||
t.Fatalf("api_token source = %q, want %q", apiToken.Source, SourceSecret)
|
||||
}
|
||||
if apiToken.Value != "secret-token" {
|
||||
t.Fatalf("api_token value = %q", apiToken.Value)
|
||||
}
|
||||
|
||||
region, _ := resolution.Get("region")
|
||||
if region.Source != SourceDefault {
|
||||
t.Fatalf("region source = %q, want %q", region.Source, SourceDefault)
|
||||
}
|
||||
if region.Value != "eu-west" {
|
||||
t.Fatalf("region value = %q, want eu-west", region.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFieldsReturnsHomogeneousMissingRequiredError(t *testing.T) {
|
||||
resolution, err := ResolveFields(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
{Name: "base_url", Required: true},
|
||||
{Name: "api_token", Required: true},
|
||||
{Name: "region"},
|
||||
},
|
||||
Lookup: StaticLookup{}.Lookup,
|
||||
})
|
||||
|
||||
var missingErr *MissingRequiredValuesError
|
||||
if !errors.As(err, &missingErr) {
|
||||
t.Fatalf("ResolveFields error = %v, want MissingRequiredValuesError", err)
|
||||
}
|
||||
|
||||
if got := strings.Join(missingErr.Fields, ","); got != "base_url,api_token" {
|
||||
t.Fatalf("missing fields = %q, want base_url,api_token", got)
|
||||
}
|
||||
|
||||
region, ok := resolution.Get("region")
|
||||
if !ok {
|
||||
t.Fatalf("region should exist in partial resolution")
|
||||
}
|
||||
if region.Found {
|
||||
t.Fatalf("region should not be found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFieldsWrapsLookupErrorsWithContext(t *testing.T) {
|
||||
lookupErr := errors.New("secret backend unavailable")
|
||||
_, err := ResolveFields(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
{
|
||||
Name: "api_token",
|
||||
Sources: []ValueSource{SourceSecret},
|
||||
},
|
||||
},
|
||||
Lookup: func(source ValueSource, key string) (string, bool, error) {
|
||||
return "", false, lookupErr
|
||||
},
|
||||
})
|
||||
|
||||
var sourceErr *SourceLookupError
|
||||
if !errors.As(err, &sourceErr) {
|
||||
t.Fatalf("ResolveFields error = %v, want SourceLookupError", err)
|
||||
}
|
||||
if sourceErr.Field != "api_token" {
|
||||
t.Fatalf("sourceErr.Field = %q, want api_token", sourceErr.Field)
|
||||
}
|
||||
if sourceErr.Source != SourceSecret {
|
||||
t.Fatalf("sourceErr.Source = %q, want %q", sourceErr.Source, SourceSecret)
|
||||
}
|
||||
if !errors.Is(err, lookupErr) {
|
||||
t.Fatalf("ResolveFields error should wrap the original lookup error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFieldsRejectsInvalidDefinitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options ResolveOptions
|
||||
}{
|
||||
{
|
||||
name: "missing lookup",
|
||||
options: ResolveOptions{
|
||||
Fields: []FieldSpec{{Name: "base_url"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty field name",
|
||||
options: ResolveOptions{
|
||||
Fields: []FieldSpec{{Name: " "}},
|
||||
Lookup: StaticLookup{}.Lookup,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate field names",
|
||||
options: ResolveOptions{
|
||||
Fields: []FieldSpec{{Name: "base_url"}, {Name: "base_url"}},
|
||||
Lookup: StaticLookup{}.Lookup,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown source in order",
|
||||
options: ResolveOptions{
|
||||
Fields: []FieldSpec{{Name: "base_url"}},
|
||||
Order: []ValueSource{"kv"},
|
||||
Lookup: StaticLookup{}.Lookup,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default source forbidden in order",
|
||||
options: ResolveOptions{
|
||||
Fields: []FieldSpec{{Name: "base_url"}},
|
||||
Order: []ValueSource{SourceDefault},
|
||||
Lookup: StaticLookup{}.Lookup,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ResolveFields(tc.options)
|
||||
if !errors.Is(err, ErrInvalidResolverInput) {
|
||||
t.Fatalf("ResolveFields error = %v, want ErrInvalidResolverInput", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderResolutionProvenance(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
err := RenderResolutionProvenance(&out, Resolution{
|
||||
Fields: []ResolvedField{
|
||||
{Name: "base_url", Source: SourceFlag, Found: true},
|
||||
{Name: "api_token", Found: false},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RenderResolutionProvenance returned error: %v", err)
|
||||
}
|
||||
|
||||
text := out.String()
|
||||
if !strings.Contains(text, "base_url: flag") {
|
||||
t.Fatalf("output = %q, want base_url provenance", text)
|
||||
}
|
||||
if !strings.Contains(text, "api_token: missing") {
|
||||
t.Fatalf("output = %q, want missing provenance", text)
|
||||
}
|
||||
}
|
||||
|
||||
530
cli/setup.go
Normal file
530
cli/setup.go
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type SetupFieldType string
|
||||
|
||||
const (
|
||||
SetupFieldString SetupFieldType = "string"
|
||||
SetupFieldURL SetupFieldType = "url"
|
||||
SetupFieldSecret SetupFieldType = "secret"
|
||||
SetupFieldBool SetupFieldType = "bool"
|
||||
SetupFieldList SetupFieldType = "list"
|
||||
)
|
||||
|
||||
var ErrInvalidSetupDefinition = errors.New("invalid setup definition")
|
||||
|
||||
type SetupField struct {
|
||||
Name string
|
||||
Label string
|
||||
Type SetupFieldType
|
||||
Required bool
|
||||
Default string
|
||||
ExistingSecret string
|
||||
ListSeparator string
|
||||
Normalize func(string) string
|
||||
Validate func(string) error
|
||||
ValidateBool func(bool) error
|
||||
ValidateList func([]string) error
|
||||
}
|
||||
|
||||
type SetupOptions struct {
|
||||
Fields []SetupField
|
||||
Stdin *os.File
|
||||
Stdout io.Writer
|
||||
}
|
||||
|
||||
type SetupValue struct {
|
||||
Type SetupFieldType
|
||||
String string
|
||||
Bool bool
|
||||
List []string
|
||||
Set bool
|
||||
KeptStoredSecret bool
|
||||
}
|
||||
|
||||
type SetupResultField struct {
|
||||
Name string
|
||||
Value SetupValue
|
||||
}
|
||||
|
||||
type SetupResult struct {
|
||||
Fields []SetupResultField
|
||||
}
|
||||
|
||||
func (r SetupResult) Get(name string) (SetupValue, bool) {
|
||||
needle := strings.TrimSpace(name)
|
||||
for _, field := range r.Fields {
|
||||
if field.Name == needle {
|
||||
return field.Value, true
|
||||
}
|
||||
}
|
||||
|
||||
return SetupValue{}, false
|
||||
}
|
||||
|
||||
type SetupValidationError struct {
|
||||
Field string
|
||||
Label string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *SetupValidationError) Error() string {
|
||||
label := strings.TrimSpace(e.Label)
|
||||
if label == "" {
|
||||
label = strings.TrimSpace(e.Field)
|
||||
}
|
||||
|
||||
if label == "" {
|
||||
return strings.TrimSpace(e.Message)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s: %s", label, strings.TrimSpace(e.Message))
|
||||
}
|
||||
|
||||
type normalizedSetupField struct {
|
||||
Name string
|
||||
Label string
|
||||
Type SetupFieldType
|
||||
Required bool
|
||||
DefaultString string
|
||||
DefaultBool *bool
|
||||
DefaultList []string
|
||||
ExistingSecret string
|
||||
ListSeparator string
|
||||
Normalize func(string) string
|
||||
Validate func(string) error
|
||||
ValidateBool func(bool) error
|
||||
ValidateList func([]string) error
|
||||
}
|
||||
|
||||
func RunSetup(options SetupOptions) (SetupResult, error) {
|
||||
stdin, stdout := normalizeSetupIO(options)
|
||||
|
||||
fields, err := normalizeSetupFields(options.Fields)
|
||||
if err != nil {
|
||||
return SetupResult{}, err
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(stdin)
|
||||
fd := int(stdin.Fd())
|
||||
isTTY := term.IsTerminal(fd)
|
||||
|
||||
result := SetupResult{
|
||||
Fields: make([]SetupResultField, 0, len(fields)),
|
||||
}
|
||||
|
||||
for _, field := range fields {
|
||||
value, err := promptSetupField(reader, stdin, stdout, fd, isTTY, field)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.Fields = append(result.Fields, SetupResultField{
|
||||
Name: field.Name,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeSetupIO(options SetupOptions) (*os.File, io.Writer) {
|
||||
stdin := options.Stdin
|
||||
if stdin == nil {
|
||||
stdin = os.Stdin
|
||||
}
|
||||
|
||||
stdout := options.Stdout
|
||||
if stdout == nil {
|
||||
stdout = os.Stdout
|
||||
}
|
||||
|
||||
return stdin, stdout
|
||||
}
|
||||
|
||||
func normalizeSetupFields(fields []SetupField) ([]normalizedSetupField, error) {
|
||||
normalized := make([]normalizedSetupField, 0, len(fields))
|
||||
seenNames := make(map[string]struct{}, len(fields))
|
||||
|
||||
for i, field := range fields {
|
||||
name := strings.TrimSpace(field.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: field at index %d has empty name", ErrInvalidSetupDefinition, i)
|
||||
}
|
||||
if _, exists := seenNames[name]; exists {
|
||||
return nil, fmt.Errorf("%w: duplicate field name %q", ErrInvalidSetupDefinition, name)
|
||||
}
|
||||
seenNames[name] = struct{}{}
|
||||
|
||||
if !isKnownSetupFieldType(field.Type) {
|
||||
return nil, fmt.Errorf("%w: field %q uses unknown type %q", ErrInvalidSetupDefinition, name, field.Type)
|
||||
}
|
||||
|
||||
label := strings.TrimSpace(field.Label)
|
||||
if label == "" {
|
||||
label = name
|
||||
}
|
||||
|
||||
normalizer := field.Normalize
|
||||
if normalizer == nil {
|
||||
normalizer = strings.TrimSpace
|
||||
}
|
||||
|
||||
listSeparator := field.ListSeparator
|
||||
if listSeparator == "" {
|
||||
listSeparator = ","
|
||||
}
|
||||
|
||||
entry := normalizedSetupField{
|
||||
Name: name,
|
||||
Label: label,
|
||||
Type: field.Type,
|
||||
Required: field.Required,
|
||||
ExistingSecret: strings.TrimSpace(field.ExistingSecret),
|
||||
ListSeparator: listSeparator,
|
||||
Normalize: normalizer,
|
||||
Validate: field.Validate,
|
||||
ValidateBool: field.ValidateBool,
|
||||
ValidateList: field.ValidateList,
|
||||
}
|
||||
|
||||
switch field.Type {
|
||||
case SetupFieldString, SetupFieldURL, SetupFieldSecret:
|
||||
entry.DefaultString = normalizer(field.Default)
|
||||
if field.Type == SetupFieldURL && entry.DefaultString != "" {
|
||||
if err := ValidateBaseURL(entry.DefaultString); err != nil {
|
||||
return nil, fmt.Errorf("%w: field %q default URL is invalid: %v", ErrInvalidSetupDefinition, name, err)
|
||||
}
|
||||
}
|
||||
case SetupFieldBool:
|
||||
defaultRaw := strings.TrimSpace(field.Default)
|
||||
if defaultRaw != "" {
|
||||
defaultValue, err := parseBoolValue(defaultRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: field %q default bool is invalid: %v", ErrInvalidSetupDefinition, name, err)
|
||||
}
|
||||
entry.DefaultBool = &defaultValue
|
||||
}
|
||||
case SetupFieldList:
|
||||
defaultRaw := strings.TrimSpace(field.Default)
|
||||
if defaultRaw != "" {
|
||||
entry.DefaultList = splitSetupList(defaultRaw, listSeparator, normalizer)
|
||||
}
|
||||
}
|
||||
|
||||
normalized = append(normalized, entry)
|
||||
}
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func promptSetupField(
|
||||
reader *bufio.Reader,
|
||||
stdin *os.File,
|
||||
stdout io.Writer,
|
||||
fd int,
|
||||
isTTY bool,
|
||||
field normalizedSetupField,
|
||||
) (SetupValue, error) {
|
||||
for {
|
||||
if err := renderSetupPrompt(stdout, field); err != nil {
|
||||
return SetupValue{}, err
|
||||
}
|
||||
|
||||
raw, err := readSetupInput(reader, stdout, fd, isTTY, field.Type)
|
||||
if err != nil {
|
||||
return SetupValue{}, fmt.Errorf("read %q: %w", field.Name, err)
|
||||
}
|
||||
|
||||
value, validationErr := parseSetupValue(field, raw)
|
||||
if validationErr == nil {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
setupErr := &SetupValidationError{
|
||||
Field: field.Name,
|
||||
Label: field.Label,
|
||||
Message: validationErr.Error(),
|
||||
}
|
||||
if !isTTY {
|
||||
return SetupValue{}, setupErr
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(stdout, "Invalid value for %s: %s\n", field.Label, validationErr.Error()); err != nil {
|
||||
return SetupValue{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readSetupInput(
|
||||
reader *bufio.Reader,
|
||||
stdout io.Writer,
|
||||
fd int,
|
||||
isTTY bool,
|
||||
fieldType SetupFieldType,
|
||||
) (string, error) {
|
||||
if fieldType == SetupFieldSecret && isTTY {
|
||||
secret, err := term.ReadPassword(fd)
|
||||
fmt.Fprintln(stdout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(secret), nil
|
||||
}
|
||||
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
|
||||
func parseSetupValue(field normalizedSetupField, raw string) (SetupValue, error) {
|
||||
switch field.Type {
|
||||
case SetupFieldString:
|
||||
return parseSetupStringValue(field, raw)
|
||||
case SetupFieldURL:
|
||||
value, err := parseSetupStringValue(field, raw)
|
||||
if err != nil {
|
||||
return SetupValue{}, err
|
||||
}
|
||||
if value.Set {
|
||||
if err := ValidateBaseURL(value.String); err != nil {
|
||||
return SetupValue{}, fmt.Errorf("must be a valid URL with scheme and host")
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
case SetupFieldSecret:
|
||||
return parseSetupSecretValue(field, raw)
|
||||
case SetupFieldBool:
|
||||
return parseSetupBoolValue(field, raw)
|
||||
case SetupFieldList:
|
||||
return parseSetupListValue(field, raw)
|
||||
default:
|
||||
return SetupValue{}, fmt.Errorf("unsupported field type %q", field.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func parseSetupStringValue(field normalizedSetupField, raw string) (SetupValue, error) {
|
||||
value := field.Normalize(raw)
|
||||
set := value != ""
|
||||
if !set && field.DefaultString != "" {
|
||||
value = field.DefaultString
|
||||
set = true
|
||||
}
|
||||
|
||||
if field.Required && !set {
|
||||
return SetupValue{}, fmt.Errorf("value is required")
|
||||
}
|
||||
|
||||
if set && field.Validate != nil {
|
||||
if err := field.Validate(value); err != nil {
|
||||
return SetupValue{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return SetupValue{
|
||||
Type: field.Type,
|
||||
String: value,
|
||||
Set: set,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseSetupSecretValue(field normalizedSetupField, raw string) (SetupValue, error) {
|
||||
value := field.Normalize(raw)
|
||||
set := value != ""
|
||||
keptStored := false
|
||||
|
||||
if !set && field.ExistingSecret != "" {
|
||||
value = field.ExistingSecret
|
||||
set = true
|
||||
keptStored = true
|
||||
} else if !set && field.DefaultString != "" {
|
||||
value = field.DefaultString
|
||||
set = true
|
||||
}
|
||||
|
||||
if field.Required && !set {
|
||||
return SetupValue{}, fmt.Errorf("value is required")
|
||||
}
|
||||
|
||||
if set && field.Validate != nil {
|
||||
if err := field.Validate(value); err != nil {
|
||||
return SetupValue{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return SetupValue{
|
||||
Type: field.Type,
|
||||
String: value,
|
||||
Set: set,
|
||||
KeptStoredSecret: keptStored,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseSetupBoolValue(field normalizedSetupField, raw string) (SetupValue, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
if field.DefaultBool != nil {
|
||||
value := *field.DefaultBool
|
||||
if field.ValidateBool != nil {
|
||||
if err := field.ValidateBool(value); err != nil {
|
||||
return SetupValue{}, err
|
||||
}
|
||||
}
|
||||
return SetupValue{
|
||||
Type: SetupFieldBool,
|
||||
Bool: value,
|
||||
Set: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if field.Required {
|
||||
return SetupValue{}, fmt.Errorf("value is required")
|
||||
}
|
||||
|
||||
return SetupValue{
|
||||
Type: SetupFieldBool,
|
||||
Set: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
value, err := parseBoolValue(trimmed)
|
||||
if err != nil {
|
||||
return SetupValue{}, err
|
||||
}
|
||||
if field.ValidateBool != nil {
|
||||
if err := field.ValidateBool(value); err != nil {
|
||||
return SetupValue{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return SetupValue{
|
||||
Type: SetupFieldBool,
|
||||
Bool: value,
|
||||
Set: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseSetupListValue(field normalizedSetupField, raw string) (SetupValue, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
|
||||
var list []string
|
||||
set := false
|
||||
if trimmed != "" {
|
||||
list = splitSetupList(trimmed, field.ListSeparator, field.Normalize)
|
||||
set = true
|
||||
} else if len(field.DefaultList) > 0 {
|
||||
list = slices.Clone(field.DefaultList)
|
||||
set = true
|
||||
}
|
||||
|
||||
if field.Required && len(list) == 0 {
|
||||
return SetupValue{}, fmt.Errorf("value is required")
|
||||
}
|
||||
|
||||
if field.ValidateList != nil {
|
||||
if err := field.ValidateList(list); err != nil {
|
||||
return SetupValue{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return SetupValue{
|
||||
Type: SetupFieldList,
|
||||
List: list,
|
||||
Set: set,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func renderSetupPrompt(w io.Writer, field normalizedSetupField) error {
|
||||
switch field.Type {
|
||||
case SetupFieldSecret:
|
||||
if field.ExistingSecret != "" {
|
||||
_, err := fmt.Fprintf(w, "%s [stored, leave blank to keep]: ", field.Label)
|
||||
return err
|
||||
}
|
||||
if field.DefaultString != "" {
|
||||
_, err := fmt.Fprintf(w, "%s [%s]: ", field.Label, field.DefaultString)
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "%s: ", field.Label)
|
||||
return err
|
||||
case SetupFieldBool:
|
||||
defaultLabel := "y/n"
|
||||
if field.DefaultBool != nil {
|
||||
if *field.DefaultBool {
|
||||
defaultLabel = "Y/n"
|
||||
} else {
|
||||
defaultLabel = "y/N"
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "%s [%s]: ", field.Label, defaultLabel)
|
||||
return err
|
||||
case SetupFieldList:
|
||||
if len(field.DefaultList) > 0 {
|
||||
_, err := fmt.Fprintf(
|
||||
w,
|
||||
"%s [%s]: ",
|
||||
field.Label,
|
||||
strings.Join(field.DefaultList, field.ListSeparator),
|
||||
)
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "%s: ", field.Label)
|
||||
return err
|
||||
case SetupFieldString, SetupFieldURL:
|
||||
if field.DefaultString != "" {
|
||||
_, err := fmt.Fprintf(w, "%s [%s]: ", field.Label, field.DefaultString)
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "%s: ", field.Label)
|
||||
return err
|
||||
default:
|
||||
_, err := fmt.Fprintf(w, "%s: ", field.Label)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func splitSetupList(raw, separator string, normalize func(string) string) []string {
|
||||
parts := strings.Split(raw, separator)
|
||||
list := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
normalized := normalize(part)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
list = append(list, normalized)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func parseBoolValue(raw string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "t", "true", "y", "yes", "on":
|
||||
return true, nil
|
||||
case "0", "f", "false", "n", "no", "off":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("must be one of: yes/no, y/n, true/false, 1/0")
|
||||
}
|
||||
}
|
||||
|
||||
func isKnownSetupFieldType(fieldType SetupFieldType) bool {
|
||||
switch fieldType {
|
||||
case SetupFieldString, SetupFieldURL, SetupFieldSecret, SetupFieldBool, SetupFieldList:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
76
cli/setup_secret.go
Normal file
76
cli/setup_secret.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type SetupSecretWriteOptions struct {
|
||||
Store secretstore.Store
|
||||
SecretName string
|
||||
SecretLabel string
|
||||
TokenEnv string
|
||||
Value SetupValue
|
||||
}
|
||||
|
||||
func WriteSetupSecretVerified(options SetupSecretWriteOptions) error {
|
||||
if options.Store == nil {
|
||||
return errors.New("secret store must not be nil")
|
||||
}
|
||||
|
||||
secretName := strings.TrimSpace(options.SecretName)
|
||||
if secretName == "" {
|
||||
return errors.New("secret name must not be empty")
|
||||
}
|
||||
|
||||
secretLabel := strings.TrimSpace(options.SecretLabel)
|
||||
if secretLabel == "" {
|
||||
secretLabel = secretName
|
||||
}
|
||||
|
||||
if options.Value.KeptStoredSecret {
|
||||
return verifyStoredSetupSecret(options.Store, secretName, options.TokenEnv)
|
||||
}
|
||||
if !options.Value.Set {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := secretstore.SetSecretVerified(options.Store, secretName, secretLabel, options.Value.String); err != nil {
|
||||
if errors.Is(err, secretstore.ErrReadOnly) {
|
||||
tokenEnv := strings.TrimSpace(options.TokenEnv)
|
||||
if tokenEnv != "" {
|
||||
return fmt.Errorf("secret store is read-only, export %s and retry setup: %w", tokenEnv, err)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("save secret %q during setup: %w", secretName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyStoredSetupSecret(store secretstore.Store, secretName, tokenEnv string) error {
|
||||
secret, err := store.GetSecret(secretName)
|
||||
if err != nil {
|
||||
if errors.Is(err, secretstore.ErrNotFound) {
|
||||
tokenEnv = strings.TrimSpace(tokenEnv)
|
||||
if tokenEnv != "" {
|
||||
return fmt.Errorf(
|
||||
"secret %q is not readable after setup, export %s and retry: %w",
|
||||
secretName,
|
||||
tokenEnv,
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("verify secret %q after setup: %w", secretName, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return fmt.Errorf("secret %q is empty after setup", secretName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
116
cli/setup_secret_test.go
Normal file
116
cli/setup_secret_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
func TestWriteSetupSecretVerifiedPersistsAndConfirmsReadability(t *testing.T) {
|
||||
store := &setupSecretStore{secrets: map[string]string{}}
|
||||
|
||||
err := WriteSetupSecretVerified(SetupSecretWriteOptions{
|
||||
Store: store,
|
||||
SecretName: "api-token",
|
||||
SecretLabel: "API token",
|
||||
Value: SetupValue{
|
||||
Type: SetupFieldSecret,
|
||||
String: "secret-v1",
|
||||
Set: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSetupSecretVerified returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := store.secrets["api-token"]; got != "secret-v1" {
|
||||
t.Fatalf("stored secret = %q, want secret-v1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSetupSecretVerifiedReturnsContextForReadOnlyStores(t *testing.T) {
|
||||
store := &setupSecretStore{
|
||||
secrets: map[string]string{},
|
||||
setErr: secretstore.ErrReadOnly,
|
||||
}
|
||||
|
||||
err := WriteSetupSecretVerified(SetupSecretWriteOptions{
|
||||
Store: store,
|
||||
SecretName: "api-token",
|
||||
TokenEnv: "GRAYLOG_MCP_API_TOKEN",
|
||||
Value: SetupValue{
|
||||
Type: SetupFieldSecret,
|
||||
String: "secret-v1",
|
||||
Set: true,
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, secretstore.ErrReadOnly) {
|
||||
t.Fatalf("error = %v, want ErrReadOnly", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "GRAYLOG_MCP_API_TOKEN") {
|
||||
t.Fatalf("error = %v, want token env remediation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSetupSecretVerifiedValidatesKeptStoredSecret(t *testing.T) {
|
||||
store := &setupSecretStore{
|
||||
secrets: map[string]string{},
|
||||
getErr: secretstore.ErrNotFound,
|
||||
}
|
||||
|
||||
err := WriteSetupSecretVerified(SetupSecretWriteOptions{
|
||||
Store: store,
|
||||
SecretName: "api-token",
|
||||
TokenEnv: "GRAYLOG_MCP_API_TOKEN",
|
||||
Value: SetupValue{
|
||||
Type: SetupFieldSecret,
|
||||
String: "stored-token",
|
||||
Set: true,
|
||||
KeptStoredSecret: true,
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, secretstore.ErrNotFound) {
|
||||
t.Fatalf("error = %v, want ErrNotFound", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "GRAYLOG_MCP_API_TOKEN") {
|
||||
t.Fatalf("error = %v, want token env remediation", err)
|
||||
}
|
||||
}
|
||||
|
||||
type setupSecretStore struct {
|
||||
secrets map[string]string
|
||||
setErr error
|
||||
getErr error
|
||||
}
|
||||
|
||||
func (s *setupSecretStore) SetSecret(name, label, secret string) error {
|
||||
if s.setErr != nil {
|
||||
return s.setErr
|
||||
}
|
||||
s.secrets[name] = secret
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *setupSecretStore) GetSecret(name string) (string, error) {
|
||||
if s.getErr != nil {
|
||||
return "", s.getErr
|
||||
}
|
||||
value, ok := s.secrets[name]
|
||||
if !ok {
|
||||
return "", secretstore.ErrNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *setupSecretStore) DeleteSecret(name string) error {
|
||||
delete(s.secrets, name)
|
||||
return nil
|
||||
}
|
||||
265
cli/setup_test.go
Normal file
265
cli/setup_test.go
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunSetupParsesTypedFieldsWithDefaultsAndNormalization(t *testing.T) {
|
||||
stdin := setupTestInputFile(t, strings.Join([]string{
|
||||
" https://api.example.com ",
|
||||
"",
|
||||
"",
|
||||
"Read, WRITE, admin",
|
||||
" eu-west ",
|
||||
}, "\n")+"\n")
|
||||
|
||||
var stdout bytes.Buffer
|
||||
result, err := RunSetup(SetupOptions{
|
||||
Stdin: stdin,
|
||||
Stdout: &stdout,
|
||||
Fields: []SetupField{
|
||||
{
|
||||
Name: "base_url",
|
||||
Label: "Base URL",
|
||||
Type: SetupFieldURL,
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "api_token",
|
||||
Label: "API token",
|
||||
Type: SetupFieldSecret,
|
||||
Required: true,
|
||||
ExistingSecret: "stored-token",
|
||||
},
|
||||
{
|
||||
Name: "enabled",
|
||||
Label: "Enabled",
|
||||
Type: SetupFieldBool,
|
||||
Default: "true",
|
||||
},
|
||||
{
|
||||
Name: "scopes",
|
||||
Label: "Scopes",
|
||||
Type: SetupFieldList,
|
||||
Default: "read,write",
|
||||
Normalize: func(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "region",
|
||||
Label: "Region",
|
||||
Type: SetupFieldString,
|
||||
Default: "eu-central",
|
||||
Normalize: strings.TrimSpace,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunSetup returned error: %v", err)
|
||||
}
|
||||
|
||||
baseURL, ok := result.Get("base_url")
|
||||
if !ok {
|
||||
t.Fatalf("missing base_url field")
|
||||
}
|
||||
if !baseURL.Set || baseURL.String != "https://api.example.com" {
|
||||
t.Fatalf("base_url = %#v, want normalized URL", baseURL)
|
||||
}
|
||||
|
||||
token, ok := result.Get("api_token")
|
||||
if !ok {
|
||||
t.Fatalf("missing api_token field")
|
||||
}
|
||||
if token.String != "stored-token" {
|
||||
t.Fatalf("token.String = %q, want stored-token", token.String)
|
||||
}
|
||||
if !token.KeptStoredSecret {
|
||||
t.Fatalf("token should keep stored secret when blank")
|
||||
}
|
||||
|
||||
enabled, ok := result.Get("enabled")
|
||||
if !ok {
|
||||
t.Fatalf("missing enabled field")
|
||||
}
|
||||
if !enabled.Bool || !enabled.Set {
|
||||
t.Fatalf("enabled = %#v, want true from default", enabled)
|
||||
}
|
||||
|
||||
scopes, ok := result.Get("scopes")
|
||||
if !ok {
|
||||
t.Fatalf("missing scopes field")
|
||||
}
|
||||
wantScopes := []string{"read", "write", "admin"}
|
||||
if !slices.Equal(scopes.List, wantScopes) {
|
||||
t.Fatalf("scopes.List = %v, want %v", scopes.List, wantScopes)
|
||||
}
|
||||
|
||||
region, ok := result.Get("region")
|
||||
if !ok {
|
||||
t.Fatalf("missing region field")
|
||||
}
|
||||
if region.String != "eu-west" {
|
||||
t.Fatalf("region.String = %q, want eu-west", region.String)
|
||||
}
|
||||
|
||||
promptOutput := stdout.String()
|
||||
for _, needle := range []string{
|
||||
"Base URL:",
|
||||
"API token [stored, leave blank to keep]:",
|
||||
"Enabled [Y/n]:",
|
||||
"Scopes [read,write]:",
|
||||
"Region [eu-central]:",
|
||||
} {
|
||||
if !strings.Contains(promptOutput, needle) {
|
||||
t.Fatalf("prompt output = %q, want substring %q", promptOutput, needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSetupReturnsReadableValidationErrorInNonInteractiveMode(t *testing.T) {
|
||||
stdin := setupTestInputFile(t, "maybe\n")
|
||||
var stdout bytes.Buffer
|
||||
|
||||
_, err := RunSetup(SetupOptions{
|
||||
Stdin: stdin,
|
||||
Stdout: &stdout,
|
||||
Fields: []SetupField{
|
||||
{
|
||||
Name: "enabled",
|
||||
Label: "Enabled",
|
||||
Type: SetupFieldBool,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
var validationErr *SetupValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("RunSetup error = %v, want SetupValidationError", err)
|
||||
}
|
||||
if validationErr.Field != "enabled" {
|
||||
t.Fatalf("validationErr.Field = %q, want enabled", validationErr.Field)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "must be one of") {
|
||||
t.Fatalf("validationErr = %v, want readable bool error", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSetupSupportsValidationHooks(t *testing.T) {
|
||||
stdin := setupTestInputFile(t, "https://example.com\nalpha,beta\n")
|
||||
|
||||
_, err := RunSetup(SetupOptions{
|
||||
Stdin: stdin,
|
||||
Fields: []SetupField{
|
||||
{
|
||||
Name: "base_url",
|
||||
Label: "Base URL",
|
||||
Type: SetupFieldURL,
|
||||
Validate: func(value string) error {
|
||||
if !strings.HasSuffix(value, "/v1") {
|
||||
return errors.New("must end with /v1")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "scopes",
|
||||
Type: SetupFieldList,
|
||||
ValidateList: func(values []string) error {
|
||||
if len(values) < 3 {
|
||||
return errors.New("at least 3 scopes are required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
var validationErr *SetupValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("RunSetup error = %v, want SetupValidationError", err)
|
||||
}
|
||||
if validationErr.Field != "base_url" {
|
||||
t.Fatalf("validationErr.Field = %q, want base_url", validationErr.Field)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "must end with /v1") {
|
||||
t.Fatalf("validationErr = %v", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSetupRejectsInvalidDefinitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fields []SetupField
|
||||
}{
|
||||
{
|
||||
name: "empty field name",
|
||||
fields: []SetupField{
|
||||
{Name: " ", Type: SetupFieldString},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate field names",
|
||||
fields: []SetupField{
|
||||
{Name: "base_url", Type: SetupFieldString},
|
||||
{Name: "base_url", Type: SetupFieldURL},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown type",
|
||||
fields: []SetupField{
|
||||
{Name: "base_url", Type: SetupFieldType("json")},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid bool default",
|
||||
fields: []SetupField{
|
||||
{Name: "enabled", Type: SetupFieldBool, Default: "sometimes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid url default",
|
||||
fields: []SetupField{
|
||||
{Name: "base_url", Type: SetupFieldURL, Default: "localhost"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := RunSetup(SetupOptions{
|
||||
Stdin: setupTestInputFile(t, ""),
|
||||
Fields: tc.fields,
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidSetupDefinition) {
|
||||
t.Fatalf("RunSetup error = %v, want ErrInvalidSetupDefinition", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setupTestInputFile(t *testing.T, content string) *os.File {
|
||||
t.Helper()
|
||||
|
||||
file, err := os.CreateTemp(t.TempDir(), "setup-input-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = file.Close()
|
||||
})
|
||||
|
||||
if _, err := file.WriteString(content); err != nil {
|
||||
t.Fatalf("WriteString returned error: %v", err)
|
||||
}
|
||||
if _, err := file.Seek(0, 0); err != nil {
|
||||
t.Fatalf("Seek returned error: %v", err)
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
269
cmd/mcp-framework/main.go
Normal file
269
cmd/mcp-framework/main.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
generatepkg "forge.lclr.dev/AI/mcp-framework/generate"
|
||||
scaffoldpkg "forge.lclr.dev/AI/mcp-framework/scaffold"
|
||||
)
|
||||
|
||||
const toolName = "mcp-framework"
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) error {
|
||||
if stdout == nil {
|
||||
stdout = io.Discard
|
||||
}
|
||||
if stderr == nil {
|
||||
stderr = io.Discard
|
||||
}
|
||||
|
||||
if len(args) == 0 || isHelpArg(args[0]) {
|
||||
printGlobalHelp(stdout)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "generate":
|
||||
return runGenerate(args[1:], stdout, stderr)
|
||||
case "scaffold":
|
||||
return runScaffold(args[1:], stdout, stderr)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runGenerate(args []string, stdout, stderr io.Writer) error {
|
||||
if shouldShowHelp(args) {
|
||||
printGenerateHelp(stdout)
|
||||
return nil
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("generate", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
var manifestPath string
|
||||
var packageDir string
|
||||
var packageName string
|
||||
var check bool
|
||||
|
||||
fs.StringVar(&manifestPath, "manifest", "", "Chemin du mcp.toml à lire (défaut: ./mcp.toml)")
|
||||
fs.StringVar(&packageDir, "package-dir", "mcpgen", "Répertoire du package Go généré")
|
||||
fs.StringVar(&packageName, "package-name", "", "Nom du package Go généré (défaut: dérivé du dossier)")
|
||||
fs.BoolVar(&check, "check", false, "Échoue si les fichiers générés ne sont pas à jour")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
_ = stderr
|
||||
return fmt.Errorf("parse generate flags: %w", err)
|
||||
}
|
||||
|
||||
if fs.NArg() > 0 {
|
||||
return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", "))
|
||||
}
|
||||
|
||||
result, err := generatepkg.Generate(generatepkg.Options{
|
||||
ManifestPath: manifestPath,
|
||||
PackageDir: packageDir,
|
||||
PackageName: packageName,
|
||||
Check: check,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if check {
|
||||
if _, err := fmt.Fprintln(stdout, "Generated files are up to date"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, file := range result.Files {
|
||||
if _, err := fmt.Fprintf(stdout, "Generated %s\n", filepath.ToSlash(file)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runScaffold(args []string, stdout, stderr io.Writer) error {
|
||||
if len(args) == 0 || isHelpArg(args[0]) {
|
||||
printScaffoldHelp(stdout)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "init":
|
||||
return runScaffoldInit(args[1:], stdout, stderr)
|
||||
default:
|
||||
return fmt.Errorf("unknown scaffold subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runScaffoldInit(args []string, stdout, stderr io.Writer) error {
|
||||
if shouldShowHelp(args) {
|
||||
printScaffoldInitHelp(stdout)
|
||||
return nil
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("scaffold init", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
var target string
|
||||
var modulePath string
|
||||
var binaryName string
|
||||
var description string
|
||||
var docsURL string
|
||||
var defaultProfile string
|
||||
var profiles string
|
||||
var knownEnv string
|
||||
var secretStorePolicy string
|
||||
var releaseDriver string
|
||||
var releaseBaseURL string
|
||||
var releaseRepository string
|
||||
var releaseTokenEnv string
|
||||
var releasePublicKeyEnv string
|
||||
var overwrite bool
|
||||
|
||||
fs.StringVar(&target, "target", "", "Répertoire cible du nouveau projet (requis)")
|
||||
fs.StringVar(&modulePath, "module", "", "Chemin de module Go du projet généré")
|
||||
fs.StringVar(&binaryName, "binary", "", "Nom du binaire généré")
|
||||
fs.StringVar(&description, "description", "", "Description bootstrap du binaire")
|
||||
fs.StringVar(&docsURL, "docs-url", "", "URL de documentation du projet")
|
||||
fs.StringVar(&defaultProfile, "default-profile", "", "Profil par défaut")
|
||||
fs.StringVar(&profiles, "profiles", "", "Liste CSV de profils connus")
|
||||
fs.StringVar(&knownEnv, "known-env", "", "Liste CSV de variables d'environnement connues")
|
||||
fs.StringVar(&secretStorePolicy, "secret-store-policy", "", "Politique secret store (auto, keyring-any, kwallet-only, env-only)")
|
||||
fs.StringVar(&releaseDriver, "release-driver", "", "Driver de release (gitea, gitlab, github)")
|
||||
fs.StringVar(&releaseBaseURL, "release-base-url", "", "Base URL de la forge release")
|
||||
fs.StringVar(&releaseRepository, "release-repository", "", "Repository release (org/repo)")
|
||||
fs.StringVar(&releaseTokenEnv, "release-token-env", "", "Nom de variable d'environnement pour le token release")
|
||||
fs.StringVar(&releasePublicKeyEnv, "release-pubkey-env", "", "Nom de variable d'environnement pour la cle publique Ed25519 de signature")
|
||||
fs.BoolVar(&overwrite, "overwrite", false, "Autorise l'écrasement des fichiers existants")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
_ = stderr
|
||||
return fmt.Errorf("parse scaffold init flags: %w", err)
|
||||
}
|
||||
|
||||
if fs.NArg() > 0 {
|
||||
return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", "))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(target) == "" {
|
||||
return errors.New("--target is required")
|
||||
}
|
||||
|
||||
result, err := scaffoldpkg.Generate(scaffoldpkg.Options{
|
||||
TargetDir: target,
|
||||
ModulePath: modulePath,
|
||||
BinaryName: binaryName,
|
||||
Description: description,
|
||||
DocsURL: docsURL,
|
||||
DefaultProfile: defaultProfile,
|
||||
Profiles: parseCSV(profiles),
|
||||
KnownEnvironmentVariables: parseCSV(knownEnv),
|
||||
SecretStorePolicy: secretStorePolicy,
|
||||
ReleaseDriver: releaseDriver,
|
||||
ReleaseBaseURL: releaseBaseURL,
|
||||
ReleaseRepository: releaseRepository,
|
||||
ReleaseTokenEnv: releaseTokenEnv,
|
||||
ReleasePublicKeyEnv: releasePublicKeyEnv,
|
||||
Overwrite: overwrite,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(stdout, "Scaffold generated in %s\n", result.Root); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, file := range result.Files {
|
||||
if _, err := fmt.Fprintf(stdout, "- %s\n", file); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printGlobalHelp(w io.Writer) {
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"Usage:\n %s <command> [options]\n\nCommands:\n generate Génère la glue Go depuis mcp.toml\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n",
|
||||
toolName,
|
||||
toolName,
|
||||
)
|
||||
}
|
||||
|
||||
func printGenerateHelp(w io.Writer) {
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"Usage:\n %s generate [flags]\n\nFlags:\n --manifest Chemin du mcp.toml à lire\n --package-dir Répertoire du package Go généré (défaut: mcpgen)\n --package-name Nom du package Go généré (défaut: dérivé du dossier)\n --check Vérifie que les fichiers générés sont à jour\n",
|
||||
toolName,
|
||||
)
|
||||
}
|
||||
|
||||
func printScaffoldHelp(w io.Writer) {
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"Usage:\n %s scaffold init [flags]\n\nSubcommands:\n init Génère un nouveau squelette MCP\n",
|
||||
toolName,
|
||||
)
|
||||
}
|
||||
|
||||
func printScaffoldInitHelp(w io.Writer) {
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"Usage:\n %s scaffold init --target <dir> [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --release-pubkey-env Variable cle publique Ed25519 release\n --overwrite Écraser les fichiers existants\n",
|
||||
toolName,
|
||||
)
|
||||
}
|
||||
|
||||
func shouldShowHelp(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if isHelpArg(arg) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isHelpArg(arg string) bool {
|
||||
switch strings.TrimSpace(arg) {
|
||||
case "-h", "--help", "help":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseCSV(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
144
cmd/mcp-framework/main_test.go
Normal file
144
cmd/mcp-framework/main_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunPrintsGlobalHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
if err := run(nil, &stdout, &stderr); err != nil {
|
||||
t.Fatalf("run returned error: %v", err)
|
||||
}
|
||||
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "mcp-framework <command>") {
|
||||
t.Fatalf("global help should mention command usage: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "scaffold init") {
|
||||
t.Fatalf("global help should mention scaffold init: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffoldInitCreatesProject(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "demo-mcp")
|
||||
args := []string{
|
||||
"scaffold", "init",
|
||||
"--target", target,
|
||||
"--module", "example.com/demo-mcp",
|
||||
"--binary", "demo-mcp",
|
||||
"--profiles", "dev,prod",
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
if err := run(args, &stdout, &stderr); err != nil {
|
||||
t.Fatalf("run returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(target, "cmd", "demo-mcp", "main.go")); err != nil {
|
||||
t.Fatalf("generated main.go missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "internal", "app", "app.go")); err != nil {
|
||||
t.Fatalf("generated app.go missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "mcp.toml")); err != nil {
|
||||
t.Fatalf("generated mcp.toml missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "install.sh")); err != nil {
|
||||
t.Fatalf("generated install.sh missing: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "Scaffold generated in") {
|
||||
t.Fatalf("stdout should include generation summary: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGenerateCreatesManifestLoader(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(`binary_name = "demo-mcp"`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := run([]string{"generate", "--manifest", filepath.Join(projectDir, "mcp.toml")}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("run returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(projectDir, "mcpgen", "manifest.go")); err != nil {
|
||||
t.Fatalf("generated manifest.go missing: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Generated mcpgen/manifest.go") {
|
||||
t.Fatalf("stdout should include generation summary: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGenerateCheckReturnsErrorWhenOutdated(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(`binary_name = "demo-mcp"`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := run([]string{"generate", "--manifest", filepath.Join(projectDir, "mcp.toml"), "--check"}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "generated files are not up to date") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffoldInitRequiresTarget(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := run([]string{"scaffold", "init"}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--target is required") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnknownCommandReturnsError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := run([]string{"boom"}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown command") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldInitHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
if err := run([]string{"scaffold", "init", "--help"}, &stdout, &stderr); err != nil {
|
||||
t.Fatalf("run returned error: %v", err)
|
||||
}
|
||||
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "--target") {
|
||||
t.Fatalf("init help should mention --target: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "--overwrite") {
|
||||
t.Fatalf("init help should mention --overwrite: %q", output)
|
||||
}
|
||||
}
|
||||
198
config/config.go
198
config/config.go
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -13,31 +14,94 @@ const (
|
|||
DefaultFile = "config.json"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFutureVersion = errors.New("future config version")
|
||||
ErrUnsupportedVersion = errors.New("unsupported config version")
|
||||
ErrInvalidConfig = errors.New("invalid config")
|
||||
)
|
||||
|
||||
type FileConfig[T any] struct {
|
||||
Version int `json:"version"`
|
||||
CurrentProfile string `json:"current_profile"`
|
||||
Profiles map[string]T `json:"profiles"`
|
||||
}
|
||||
|
||||
type ValidationIssue struct {
|
||||
Path string
|
||||
Message string
|
||||
}
|
||||
|
||||
type ValidationError struct {
|
||||
Issues []ValidationIssue
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, 0, len(e.Issues))
|
||||
for _, issue := range e.Issues {
|
||||
if issue.Path == "" {
|
||||
parts = append(parts, issue.Message)
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s: %s", issue.Path, issue.Message))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("invalid config: %s", strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
func (e *ValidationError) Unwrap() error {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
|
||||
type Migration func(doc map[string]json.RawMessage) error
|
||||
|
||||
type Validator[T any] func(FileConfig[T]) []ValidationIssue
|
||||
|
||||
type Options[T any] struct {
|
||||
FileName string
|
||||
Version int
|
||||
Migrations map[int]Migration
|
||||
Validator Validator[T]
|
||||
}
|
||||
|
||||
type Store[T any] struct {
|
||||
dirName string
|
||||
fileName string
|
||||
dirName string
|
||||
fileName string
|
||||
version int
|
||||
migrations map[int]Migration
|
||||
validator Validator[T]
|
||||
}
|
||||
|
||||
func NewStore[T any](dirName string) Store[T] {
|
||||
return NewStoreWithFile[T](dirName, DefaultFile)
|
||||
return NewStoreWithOptions[T](dirName, Options[T]{})
|
||||
}
|
||||
|
||||
func NewStoreWithFile[T any](dirName, fileName string) Store[T] {
|
||||
return NewStoreWithOptions[T](dirName, Options[T]{FileName: fileName})
|
||||
}
|
||||
|
||||
func NewStoreWithOptions[T any](dirName string, options Options[T]) Store[T] {
|
||||
fileName := options.FileName
|
||||
if fileName == "" {
|
||||
fileName = DefaultFile
|
||||
}
|
||||
|
||||
version := options.Version
|
||||
if version <= 0 {
|
||||
version = CurrentVersion
|
||||
}
|
||||
|
||||
return Store[T]{
|
||||
dirName: dirName,
|
||||
fileName: fileName,
|
||||
dirName: dirName,
|
||||
fileName: fileName,
|
||||
version: version,
|
||||
migrations: buildMigrations(version, options.Migrations),
|
||||
validator: options.Validator,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Store[T]) Default() FileConfig[T] {
|
||||
return FileConfig[T]{
|
||||
Version: CurrentVersion,
|
||||
Version: s.version,
|
||||
Profiles: map[string]T{},
|
||||
}
|
||||
}
|
||||
|
|
@ -64,12 +128,29 @@ func (s Store[T]) Load(path string) (FileConfig[T], error) {
|
|||
return s.Default(), nil
|
||||
}
|
||||
|
||||
doc, err := parseDocument(path, data)
|
||||
if err != nil {
|
||||
return FileConfig[T]{}, err
|
||||
}
|
||||
|
||||
if err := s.migrateDocument(path, doc); err != nil {
|
||||
return FileConfig[T]{}, err
|
||||
}
|
||||
|
||||
cfg := s.Default()
|
||||
data, err = json.Marshal(doc)
|
||||
if err != nil {
|
||||
return FileConfig[T]{}, fmt.Errorf("encode config %s: %w", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig[T]{}, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
|
||||
s.normalize(&cfg)
|
||||
if err := s.validate(cfg); err != nil {
|
||||
return FileConfig[T]{}, fmt.Errorf("validate config %s: %w", path, err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
|
@ -89,6 +170,9 @@ func (s Store[T]) LoadDefault() (FileConfig[T], string, error) {
|
|||
|
||||
func (s Store[T]) Save(path string, cfg FileConfig[T]) error {
|
||||
s.normalize(&cfg)
|
||||
if err := s.validate(cfg); err != nil {
|
||||
return fmt.Errorf("validate config %s: %w", path, err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
|
|
@ -153,9 +237,109 @@ func (s Store[T]) SaveDefault(cfg FileConfig[T]) (string, error) {
|
|||
|
||||
func (s Store[T]) normalize(cfg *FileConfig[T]) {
|
||||
if cfg.Version == 0 {
|
||||
cfg.Version = CurrentVersion
|
||||
cfg.Version = s.version
|
||||
}
|
||||
if cfg.Profiles == nil {
|
||||
cfg.Profiles = map[string]T{}
|
||||
}
|
||||
}
|
||||
|
||||
func (s Store[T]) validate(cfg FileConfig[T]) error {
|
||||
issues := make([]ValidationIssue, 0, 2)
|
||||
if cfg.Version != s.version {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: "version",
|
||||
Message: fmt.Sprintf("must be %d", s.version),
|
||||
})
|
||||
}
|
||||
if cfg.CurrentProfile != "" {
|
||||
if _, ok := cfg.Profiles[cfg.CurrentProfile]; !ok {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: "current_profile",
|
||||
Message: fmt.Sprintf("references unknown profile %q", cfg.CurrentProfile),
|
||||
})
|
||||
}
|
||||
}
|
||||
if s.validator != nil {
|
||||
issues = append(issues, s.validator(cfg)...)
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ValidationError{Issues: issues}
|
||||
}
|
||||
|
||||
func (s Store[T]) migrateDocument(path string, doc map[string]json.RawMessage) error {
|
||||
version, err := documentVersion(doc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
if version > s.version {
|
||||
return fmt.Errorf("%w: config version %d is newer than supported version %d", ErrFutureVersion, version, s.version)
|
||||
}
|
||||
|
||||
for version < s.version {
|
||||
migration, ok := s.migrations[version]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: no migration registered from version %d to %d", ErrUnsupportedVersion, version, version+1)
|
||||
}
|
||||
if err := migration(doc); err != nil {
|
||||
return fmt.Errorf("migrate config %s from version %d to %d: %w", path, version, version+1, err)
|
||||
}
|
||||
version++
|
||||
if err := setDocumentVersion(doc, version); err != nil {
|
||||
return fmt.Errorf("set config version %s to %d: %w", path, version, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMigrations(version int, custom map[int]Migration) map[int]Migration {
|
||||
migrations := map[int]Migration{}
|
||||
if version >= 1 {
|
||||
migrations[0] = func(doc map[string]json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for fromVersion, migration := range custom {
|
||||
migrations[fromVersion] = migration
|
||||
}
|
||||
|
||||
return migrations
|
||||
}
|
||||
|
||||
func parseDocument(path string, data []byte) (map[string]json.RawMessage, error) {
|
||||
var doc map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func documentVersion(doc map[string]json.RawMessage) (int, error) {
|
||||
rawVersion, ok := doc["version"]
|
||||
if !ok || len(rawVersion) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var version int
|
||||
if err := json.Unmarshal(rawVersion, &version); err != nil {
|
||||
return 0, fmt.Errorf("decode version: %w", err)
|
||||
}
|
||||
if version < 0 {
|
||||
return 0, fmt.Errorf("%w: version must be non-negative", ErrUnsupportedVersion)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func setDocumentVersion(doc map[string]json.RawMessage, version int) error {
|
||||
rawVersion, err := json.Marshal(version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
doc["version"] = rawVersion
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -28,6 +31,40 @@ func TestLoadMissingReturnsDefault(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoadMigratesLegacyConfigAndPreservesProfiles(t *testing.T) {
|
||||
store := NewStore[testProfile]("mcp-framework-test")
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
data := []byte(`{
|
||||
"current_profile": "prod",
|
||||
"profiles": {
|
||||
"prod": {
|
||||
"base_url": "https://api.example.com",
|
||||
"stream_id": "stream-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := store.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Version != CurrentVersion {
|
||||
t.Fatalf("Version = %d, want %d", cfg.Version, CurrentVersion)
|
||||
}
|
||||
if cfg.CurrentProfile != "prod" {
|
||||
t.Fatalf("CurrentProfile = %q, want prod", cfg.CurrentProfile)
|
||||
}
|
||||
if cfg.Profiles["prod"].StreamID != "stream-1" {
|
||||
t.Fatalf("StreamID = %q, want stream-1", cfg.Profiles["prod"].StreamID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||
store := NewStore[testProfile]("mcp-framework-test")
|
||||
dir := t.TempDir()
|
||||
|
|
@ -76,3 +113,135 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
|
|||
t.Fatalf("BaseURL = %q", cfg.Profiles["prod"].BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesRegisteredMigrations(t *testing.T) {
|
||||
store := NewStoreWithOptions[testProfile]("mcp-framework-test", Options[testProfile]{
|
||||
Version: 2,
|
||||
Migrations: map[int]Migration{
|
||||
1: func(doc map[string]json.RawMessage) error {
|
||||
doc["migrated"] = json.RawMessage(`true`)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
data := []byte(`{
|
||||
"version": 1,
|
||||
"current_profile": "prod",
|
||||
"profiles": {
|
||||
"prod": {
|
||||
"base_url": "https://api.example.com",
|
||||
"stream_id": "stream-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := store.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Version != 2 {
|
||||
t.Fatalf("Version = %d, want 2", cfg.Version)
|
||||
}
|
||||
if cfg.CurrentProfile != "prod" {
|
||||
t.Fatalf("CurrentProfile = %q, want prod", cfg.CurrentProfile)
|
||||
}
|
||||
if cfg.Profiles["prod"].BaseURL != "https://api.example.com" {
|
||||
t.Fatalf("BaseURL = %q, want https://api.example.com", cfg.Profiles["prod"].BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsFutureVersion(t *testing.T) {
|
||||
store := NewStore[testProfile]("mcp-framework-test")
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
data := []byte(`{
|
||||
"version": 2,
|
||||
"profiles": {}
|
||||
}
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err := store.Load(path)
|
||||
if !errors.Is(err, ErrFutureVersion) {
|
||||
t.Fatalf("Load error = %v, want ErrFutureVersion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingMigrationPath(t *testing.T) {
|
||||
store := NewStoreWithOptions[testProfile]("mcp-framework-test", Options[testProfile]{Version: 2})
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
data := []byte(`{
|
||||
"version": 1,
|
||||
"profiles": {}
|
||||
}
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err := store.Load(path)
|
||||
if !errors.Is(err, ErrUnsupportedVersion) {
|
||||
t.Fatalf("Load error = %v, want ErrUnsupportedVersion", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no migration registered from version 1 to 2") {
|
||||
t.Fatalf("Load error = %v, want missing migration detail", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadValidatesConfig(t *testing.T) {
|
||||
store := NewStoreWithOptions[testProfile]("mcp-framework-test", Options[testProfile]{
|
||||
Validator: func(cfg FileConfig[testProfile]) []ValidationIssue {
|
||||
issues := make([]ValidationIssue, 0, len(cfg.Profiles))
|
||||
for name, profile := range cfg.Profiles {
|
||||
if profile.BaseURL == "" {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: "profiles." + name + ".base_url",
|
||||
Message: "must not be empty",
|
||||
})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
},
|
||||
})
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
data := []byte(`{
|
||||
"version": 1,
|
||||
"current_profile": "prod",
|
||||
"profiles": {
|
||||
"prod": {
|
||||
"stream_id": "stream-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err := store.Load(path)
|
||||
if !errors.Is(err, ErrInvalidConfig) {
|
||||
t.Fatalf("Load error = %v, want ErrInvalidConfig", err)
|
||||
}
|
||||
|
||||
var validationErr *ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("Load error = %v, want ValidationError", err)
|
||||
}
|
||||
if len(validationErr.Issues) != 1 {
|
||||
t.Fatalf("Validation issues = %v, want 1 issue", validationErr.Issues)
|
||||
}
|
||||
if validationErr.Issues[0].Path != "profiles.prod.base_url" {
|
||||
t.Fatalf("Validation issue path = %q, want profiles.prod.base_url", validationErr.Issues[0].Path)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
docs/README.md
Normal file
20
docs/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Documentation mcp-framework
|
||||
|
||||
`mcp-framework` fournit des packages Go et un CLI pour construire des binaires
|
||||
MCP avec une base commune : bootstrap CLI, configuration, secrets, manifeste,
|
||||
génération de code, scaffold, diagnostic et auto-update.
|
||||
|
||||
## Navigation
|
||||
|
||||
- [Installation et utilisation type](getting-started.md)
|
||||
- [Packages](packages.md)
|
||||
- [Bootstrap CLI](bootstrap-cli.md)
|
||||
- [Manifeste `mcp.toml`](manifest.md)
|
||||
- [Génération depuis `mcp.toml`](generate.md)
|
||||
- [Scaffolding](scaffolding.md)
|
||||
- [Config JSON](config.md)
|
||||
- [Secrets](secrets.md)
|
||||
- [Helpers CLI](cli-helpers.md)
|
||||
- [Auto-update](auto-update.md)
|
||||
- [Exemple minimal](minimal-example.md)
|
||||
- [Limites](limitations.md)
|
||||
51
docs/auto-update.md
Normal file
51
docs/auto-update.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Auto-update
|
||||
|
||||
Le package `update` supporte les drivers `gitea`, `gitlab` et `github`.
|
||||
Si `latest_release_url` est vide, l'URL latest est déduite depuis `driver + repository (+ base_url)`.
|
||||
|
||||
Le parseur de release supporte :
|
||||
|
||||
- format `assets.links` (Gitea/GitLab)
|
||||
- format `assets[]` avec `browser_download_url` (GitHub et Gitea API)
|
||||
|
||||
Le format attendu pour la réponse `latest release` est :
|
||||
|
||||
```json
|
||||
{
|
||||
"tag_name": "v1.2.3",
|
||||
"assets": {
|
||||
"links": [
|
||||
{
|
||||
"name": "my-mcp-linux-amd64",
|
||||
"url": "https://example.com/downloads/my-mcp-linux-amd64"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Exemple :
|
||||
|
||||
```go
|
||||
file, _, err := manifest.LoadDefault(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = update.Run(ctx, update.Options{
|
||||
CurrentVersion: version,
|
||||
BinaryName: "my-mcp",
|
||||
ReleaseSource: file.Update.ReleaseSource(),
|
||||
Stdout: os.Stdout,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Comportement :
|
||||
|
||||
- le nom de l'asset est configurable (`asset_name_template`) et supporte tout couple `GOOS/GOARCH`
|
||||
- si un asset `<asset>.sha256` (ou `checksum_asset_name`) existe, le binaire téléchargé est vérifié avant remplacement
|
||||
- un hook `ValidateDownloaded` permet d'ajouter une validation custom (signature, scan, etc.)
|
||||
- sur Windows, le remplacement in-place n'est pas fait par défaut ; fournir `Options.ReplaceExecutable` pour une stratégie dédiée
|
||||
152
docs/bootstrap-cli.md
Normal file
152
docs/bootstrap-cli.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# Bootstrap CLI
|
||||
|
||||
Le package `bootstrap` fournit un point d'entrée CLI uniforme pour les binaires
|
||||
MCP. Il gère le parsing des arguments, l'aide, les alias et le routage vers les
|
||||
hooks fournis par l'application.
|
||||
|
||||
## Commandes disponibles
|
||||
|
||||
| Commande | Description |
|
||||
|---|---|
|
||||
| `setup` | Initialiser ou mettre à jour la configuration locale |
|
||||
| `login` | Authentifier et déverrouiller Bitwarden pour persister `BW_SESSION` |
|
||||
| `mcp` | Démarrer le serveur MCP |
|
||||
| `config show` | Afficher la configuration résolue et la provenance des valeurs |
|
||||
| `config test` | Vérifier la configuration et la connectivité |
|
||||
| `config delete` | Supprimer un profil local |
|
||||
| `update` | Auto-update du binaire |
|
||||
| `version` | Afficher la version |
|
||||
|
||||
Les commandes sans hook correspondant sont automatiquement masquées de l'aide et
|
||||
retournent une erreur `ErrUnknownCommand`. Exception : `version` affiche
|
||||
`Options.Version` si fourni, sans hook.
|
||||
|
||||
## Utilisation
|
||||
|
||||
```go
|
||||
func main() {
|
||||
err := bootstrap.Run(context.Background(), bootstrap.Options{
|
||||
BinaryName: "my-mcp",
|
||||
Description: "Client MCP",
|
||||
Version: version,
|
||||
EnableDoctorAlias: true,
|
||||
Hooks: bootstrap.Hooks{
|
||||
Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||
return runSetup(ctx, inv.Args)
|
||||
},
|
||||
Login: bootstrap.BitwardenLoginHandler("my-mcp"),
|
||||
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||
return runMCP(ctx, inv.Args)
|
||||
},
|
||||
ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||
return runConfigShow(ctx, inv.Args)
|
||||
},
|
||||
ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{
|
||||
OpenStore: openStore,
|
||||
ConnectivityCheck: connectivityCheck,
|
||||
}),
|
||||
Update: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||
return runUpdate(ctx, inv.Args)
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Handlers fournis
|
||||
|
||||
### `BitwardenLoginHandler`
|
||||
|
||||
```go
|
||||
bootstrap.BitwardenLoginHandler(binaryName string) bootstrap.Handler
|
||||
```
|
||||
|
||||
Handler prêt à l'emploi pour la commande `login` des MCPs qui utilisent le
|
||||
backend Bitwarden CLI. Il lance le flux interactif `bw unlock --raw`, persiste
|
||||
`BW_SESSION` dans un fichier `0600` sous le répertoire de config utilisateur, et
|
||||
confirme le résultat.
|
||||
|
||||
À n'utiliser que si le MCP déclare `secret_store.backend_policy = "bitwarden-cli"`
|
||||
dans son manifest. Pour les autres backends (`env-only`, `keyring-any`), ne pas
|
||||
définir de hook `Login` : la commande est automatiquement masquée.
|
||||
|
||||
```go
|
||||
Hooks: bootstrap.Hooks{
|
||||
Login: bootstrap.BitwardenLoginHandler(mcpgen.BinaryName),
|
||||
}
|
||||
```
|
||||
|
||||
### `StandardConfigTestHandler`
|
||||
|
||||
```go
|
||||
bootstrap.StandardConfigTestHandler(opts bootstrap.StandardConfigTestOptions) bootstrap.Handler
|
||||
```
|
||||
|
||||
Handler pour `config test` qui exécute un ensemble de checks standards et affiche
|
||||
un rapport formaté. Aucun champ n'est obligatoire.
|
||||
|
||||
```go
|
||||
type StandardConfigTestOptions struct {
|
||||
ConfigCheck cli.DoctorCheck // cli.NewConfigCheck(store)
|
||||
OpenStore func() (secretstore.Store, error) // check disponibilité secret store
|
||||
ConnectivityCheck cli.DoctorCheck // check applicatif (HTTP, IMAP…)
|
||||
ExtraChecks []cli.DoctorCheck
|
||||
}
|
||||
```
|
||||
|
||||
Checks inclus automatiquement selon les champs fournis :
|
||||
|
||||
| Champ | Check résultant |
|
||||
|---|---|
|
||||
| `ConfigCheck` | Fichier de configuration lisible |
|
||||
| `OpenStore` | Secret store disponible |
|
||||
| `ConnectivityCheck` | Connectivité applicative |
|
||||
| `ExtraChecks` | Checks supplémentaires |
|
||||
|
||||
Le `ManifestCheck` n'est pas inclus : le manifest est un artefact de build, pas
|
||||
une contrainte runtime.
|
||||
|
||||
```go
|
||||
ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{
|
||||
ConfigCheck: cli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig]("my-mcp")),
|
||||
OpenStore: openSecretStore,
|
||||
ConnectivityCheck: func(ctx context.Context) cli.DoctorResult {
|
||||
if err := pingBackend(ctx); err != nil {
|
||||
return cli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: cli.DoctorStatusFail,
|
||||
Summary: "backend inaccessible",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
return cli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: cli.DoctorStatusOK,
|
||||
Summary: "backend accessible",
|
||||
}
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
Pour un config test applicatif spécifique (appels API, messages ✓/✗), implémenter
|
||||
un hook `ConfigTest` custom.
|
||||
|
||||
## Options
|
||||
|
||||
| Champ | Description |
|
||||
|---|---|
|
||||
| `BinaryName` | Nom du binaire, utilisé dans l'aide et les messages |
|
||||
| `Description` | Description affichée dans l'aide globale |
|
||||
| `Version` | Version affichée par `version` sans hook |
|
||||
| `Args` | Arguments CLI (défaut : `os.Args[1:]`) |
|
||||
| `Stdin/Stdout/Stderr` | I/O (défaut : `os.Stdin/Stdout/Stderr`) |
|
||||
| `Aliases` | Alias de commandes |
|
||||
| `AliasDescriptions` | Descriptions des alias dans l'aide |
|
||||
| `EnableDoctorAlias` | Active `doctor` comme alias de `config test` |
|
||||
| `DisabledCommands` | Commandes à masquer explicitement |
|
||||
|
||||
Le flag global `--debug` active le debug des appels Bitwarden
|
||||
(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`).
|
||||
208
docs/cli-helpers.md
Normal file
208
docs/cli-helpers.md
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
# Helpers CLI
|
||||
|
||||
`cli` fournit des helpers simples pour les assistants interactifs :
|
||||
|
||||
```go
|
||||
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
|
||||
|
||||
baseURL, err := cli.PromptLine(reader, os.Stdout, "Base URL", profile.BaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cli.ValidateBaseURL(baseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := cli.PromptSecret(os.Stdin, os.Stdout, "API token", hasStoredSecret, storedToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = token
|
||||
```
|
||||
|
||||
Pour décrire un setup complet sans réécrire la boucle interactive :
|
||||
|
||||
```go
|
||||
result, err := cli.RunSetup(cli.SetupOptions{
|
||||
Stdin: os.Stdin,
|
||||
Stdout: os.Stdout,
|
||||
Fields: []cli.SetupField{
|
||||
{
|
||||
Name: "base_url",
|
||||
Label: "Base URL",
|
||||
Type: cli.SetupFieldURL,
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "api_token",
|
||||
Label: "API token",
|
||||
Type: cli.SetupFieldSecret,
|
||||
Required: true,
|
||||
ExistingSecret: storedToken,
|
||||
},
|
||||
{
|
||||
Name: "enabled",
|
||||
Label: "Enable integration",
|
||||
Type: cli.SetupFieldBool,
|
||||
Default: "true",
|
||||
},
|
||||
{
|
||||
Name: "scopes",
|
||||
Label: "Scopes",
|
||||
Type: cli.SetupFieldList,
|
||||
Default: "read,write",
|
||||
Normalize: func(value string) string { return strings.TrimSpace(strings.ToLower(value)) },
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseURL, _ := result.Get("base_url")
|
||||
apiToken, _ := result.Get("api_token")
|
||||
enabled, _ := result.Get("enabled")
|
||||
scopes, _ := result.Get("scopes")
|
||||
|
||||
if apiToken.KeptStoredSecret {
|
||||
fmt.Println("Stored token kept.")
|
||||
}
|
||||
|
||||
_ = baseURL
|
||||
_ = enabled
|
||||
_ = scopes
|
||||
```
|
||||
|
||||
Pour persister un secret de setup avec write+read-back et messages homogènes :
|
||||
|
||||
```go
|
||||
if err := cli.WriteSetupSecretVerified(cli.SetupSecretWriteOptions{
|
||||
Store: store,
|
||||
SecretName: "api-token",
|
||||
SecretLabel: "API token",
|
||||
TokenEnv: "MY_MCP_API_TOKEN",
|
||||
Value: apiToken,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Chaque champ peut déclarer ses propres hooks de validation (`Validate`, `ValidateBool`, `ValidateList`).
|
||||
Les validations sont appliquées de manière cohérente en TTY et en stdin non interactif.
|
||||
|
||||
Pour standardiser la résolution `flag > env > config > secret` avec provenance :
|
||||
|
||||
```go
|
||||
store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||
ServiceName: "my-mcp",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lookup := cli.ResolveLookup(cli.ResolveLookupOptions{
|
||||
Flag: cli.MapLookup(flagValues),
|
||||
Env: cli.EnvLookup(os.LookupEnv),
|
||||
Config: cli.ConfigMap(configValues),
|
||||
Secret: cli.SecretStore(store),
|
||||
})
|
||||
|
||||
resolution, err := cli.ResolveFields(cli.ResolveOptions{
|
||||
Fields: []cli.FieldSpec{
|
||||
{Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"},
|
||||
{Name: "api_token", Required: true, EnvKey: "MY_MCP_API_TOKEN", SecretKey: "my-mcp-api-token"},
|
||||
{Name: "timeout", DefaultValue: "30s"},
|
||||
},
|
||||
Lookup: lookup,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et `FieldSpec.Sources` permet de définir un ordre spécifique pour un champ.
|
||||
|
||||
Le package fournit un socle réutilisable pour une commande `doctor`.
|
||||
Pour les cas standards (config, secret store, connectivité), préférer
|
||||
`bootstrap.StandardConfigTestHandler` qui câble `RunDoctor` sans boilerplate.
|
||||
|
||||
Pour un contrôle fin ou un config test impératif, utiliser `RunDoctor` directement :
|
||||
|
||||
```go
|
||||
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
||||
ConfigCheck: cli.NewConfigCheck(store),
|
||||
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) {
|
||||
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||
ServiceName: "my-mcp",
|
||||
})
|
||||
}),
|
||||
ConnectivityCheck: func(context.Context) cli.DoctorResult {
|
||||
if err := pingBackend(); err != nil {
|
||||
return cli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: cli.DoctorStatusFail,
|
||||
Summary: "backend inaccessible",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
return cli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: cli.DoctorStatusOK,
|
||||
Summary: "backend accessible",
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if err := cli.RenderDoctorReport(os.Stdout, report); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if report.HasFailures() {
|
||||
os.Exit(1)
|
||||
}
|
||||
```
|
||||
|
||||
`ManifestDir` est optionnel. Quand il est fourni, `RunDoctor` inclut un
|
||||
`ManifestCheck` qui vérifie la présence et la validité de `mcp.toml` dans ce
|
||||
répertoire. Ne l'inclure que si ce check est pertinent pour l'application.
|
||||
|
||||
Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement.
|
||||
Pour le désactiver explicitement :
|
||||
|
||||
```go
|
||||
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
||||
DisableAutoBitwardenCheck: true,
|
||||
})
|
||||
```
|
||||
|
||||
Pour éviter des checks custom répétitifs sur les profils, `doctor` expose aussi un helper basé sur `FieldSpec` :
|
||||
|
||||
```go
|
||||
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
||||
ExtraChecks: []cli.DoctorCheck{
|
||||
cli.RequiredResolvedFieldsCheck(cli.ResolveOptions{
|
||||
Fields: []cli.FieldSpec{
|
||||
{Name: "base_url", Required: true, EnvKey: "MY_MCP_BASE_URL"},
|
||||
{
|
||||
Name: "api_token",
|
||||
Required: true,
|
||||
EnvKey: "MY_MCP_API_TOKEN",
|
||||
SecretKey: "my-mcp-api-token",
|
||||
Sources: []cli.ValueSource{cli.SourceEnv, cli.SourceSecret},
|
||||
},
|
||||
},
|
||||
Lookup: cli.ResolveLookup(cli.ResolveLookupOptions{
|
||||
Env: cli.EnvLookup(os.LookupEnv),
|
||||
Config: cli.ConfigMap(configValues),
|
||||
Secret: cli.SecretStore(secretStore),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
En cas d'échec de résolution, tu peux aussi réutiliser le formatteur `cli.FormatResolveFieldsError(err)` dans un check custom pour garder des messages homogènes.
|
||||
64
docs/config.md
Normal file
64
docs/config.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Config JSON
|
||||
|
||||
Le package `config` stocke une structure générique par profil dans un JSON privé pour l'utilisateur courant.
|
||||
|
||||
Exemple :
|
||||
|
||||
```go
|
||||
type Profile struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
store := config.NewStore[Profile]("my-mcp")
|
||||
|
||||
cfg, path, err := store.LoadDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
|
||||
profile := cfg.Profiles[profileName]
|
||||
|
||||
profile.BaseURL = "https://api.example.com"
|
||||
cfg.CurrentProfile = profileName
|
||||
cfg.Profiles[profileName] = profile
|
||||
|
||||
_, err = store.SaveDefault(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("config saved to %s\n", path)
|
||||
```
|
||||
|
||||
Notes :
|
||||
|
||||
- le fichier est créé avec des permissions `0600`
|
||||
- le répertoire parent est forcé en `0700`
|
||||
- l'écriture est atomique via un fichier temporaire puis `rename`
|
||||
- si le fichier n'existe pas, `Load` et `LoadDefault` retournent une config vide par défaut
|
||||
- `NewStoreWithOptions` permet de définir une version cible, des migrations JSON (`from -> to`) et une validation explicite après chargement
|
||||
- une config plus récente que la version supportée, ou sans chemin de migration complet, retourne une erreur explicite
|
||||
|
||||
Exemple de store versionné :
|
||||
|
||||
```go
|
||||
store := config.NewStoreWithOptions[Profile]("my-mcp", config.Options[Profile]{
|
||||
Version: 2,
|
||||
Migrations: map[int]config.Migration{
|
||||
1: func(doc map[string]json.RawMessage) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Validator: func(cfg config.FileConfig[Profile]) []config.ValidationIssue {
|
||||
if cfg.CurrentProfile == "" {
|
||||
return []config.ValidationIssue{{
|
||||
Path: "current_profile",
|
||||
Message: "must not be empty",
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
```
|
||||
150
docs/generate.md
Normal file
150
docs/generate.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Génération depuis `mcp.toml`
|
||||
|
||||
La commande `mcp-framework generate` génère la glue Go dérivée du manifeste
|
||||
racine d'un projet existant.
|
||||
|
||||
## Usage
|
||||
|
||||
Depuis la racine du projet Go :
|
||||
|
||||
```bash
|
||||
mcp-framework generate
|
||||
```
|
||||
|
||||
La commande lit `./mcp.toml`, valide son contenu avec le package `manifest`, et
|
||||
génère :
|
||||
|
||||
```text
|
||||
mcpgen/
|
||||
manifest.go
|
||||
metadata.go
|
||||
update.go
|
||||
secretstore.go
|
||||
config.go # si [[config.fields]] existe
|
||||
```
|
||||
|
||||
Le package généré expose le loader de manifeste :
|
||||
|
||||
```go
|
||||
func LoadManifest(startDir string) (manifest.File, string, error)
|
||||
```
|
||||
|
||||
Cette fonction appelle `manifest.LoadDefaultOrEmbedded`. En développement, un
|
||||
`mcp.toml` présent sur disque reste prioritaire. Pour un binaire copié seul,
|
||||
elle utilise le contenu du manifeste embarqué au moment de la génération.
|
||||
|
||||
Il expose aussi des helpers dérivés du manifeste :
|
||||
|
||||
```go
|
||||
const BinaryName = "my-mcp"
|
||||
const DefaultDescription = "..."
|
||||
const DocsURL = "..."
|
||||
|
||||
func BootstrapInfo(startDir string) (manifest.BootstrapMetadata, string, error)
|
||||
func ScaffoldInfo(startDir string) (manifest.ScaffoldMetadata, string, error)
|
||||
```
|
||||
|
||||
Pour l'auto-update :
|
||||
|
||||
```go
|
||||
func UpdateOptions(version string, stdout io.Writer) (update.Options, error)
|
||||
func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (update.Options, error)
|
||||
func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error
|
||||
func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error
|
||||
```
|
||||
|
||||
`RunUpdate` parse les flags de la commande `update`, refuse les arguments
|
||||
positionnels, charge le manifeste via `LoadManifest`, puis appelle
|
||||
`update.Run`.
|
||||
|
||||
Pour les secrets :
|
||||
|
||||
```go
|
||||
type SecretStoreOptions struct {
|
||||
ServiceName string
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
func OpenSecretStore(options SecretStoreOptions) (secretstore.Store, error)
|
||||
func DescribeSecretRuntime(options SecretStoreOptions) (secretstore.RuntimeDescription, error)
|
||||
func PreflightSecretStore(options SecretStoreOptions) (secretstore.PreflightReport, error)
|
||||
```
|
||||
|
||||
`SecretStoreOptions` contient aussi les options techniques du package
|
||||
`secretstore` (`KWalletAppID`, `KWalletFolder`, `BitwardenCommand`,
|
||||
`BitwardenDebug`, `Shell`, `ExecutableResolver`). Si `ServiceName` est vide,
|
||||
le nom du binaire déclaré dans le manifeste est utilisé.
|
||||
|
||||
Si le manifest déclare `[[config.fields]]`, le package généré expose aussi :
|
||||
|
||||
```go
|
||||
type ConfigFlags struct { /* champs internes */ }
|
||||
|
||||
func AddConfigFlags(fs *flag.FlagSet) ConfigFlags
|
||||
func ConfigFlagValues(flags ConfigFlags) map[string]string
|
||||
func ResolveFieldSpecs(profile string) []cli.FieldSpec
|
||||
func SetupFields(existing map[string]string) []cli.SetupField
|
||||
```
|
||||
|
||||
`AddConfigFlags` branche les flags déclarés sur le `FlagSet` du projet.
|
||||
`ConfigFlagValues` retourne uniquement les valeurs de flags non vides.
|
||||
`ResolveFieldSpecs` génère les specs à passer à `cli.ResolveFields`, en
|
||||
remplaçant `{profile}` dans les templates de secrets. `SetupFields` génère les
|
||||
champs attendus par `cli.RunSetup`; le paramètre `existing` permet de fournir
|
||||
les secrets déjà stockés par nom de champ.
|
||||
|
||||
## Flags
|
||||
|
||||
- `--manifest` : chemin du `mcp.toml` à lire. Par défaut, `./mcp.toml`.
|
||||
- `--package-dir` : répertoire du package généré. Par défaut, `mcpgen`.
|
||||
- `--package-name` : nom du package Go généré. Par défaut, dérivé du dossier.
|
||||
- `--check` : mode CI, échoue si les fichiers générés sont absents ou obsolètes.
|
||||
|
||||
Exemple CI :
|
||||
|
||||
```bash
|
||||
mcp-framework generate --check
|
||||
```
|
||||
|
||||
## Utilisation dans l'application
|
||||
|
||||
Importer le package généré depuis le module de l'application :
|
||||
|
||||
```go
|
||||
import "example.com/my-mcp/mcpgen"
|
||||
```
|
||||
|
||||
Charger le manifeste :
|
||||
|
||||
```go
|
||||
file, source, err := mcpgen.LoadManifest(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = file
|
||||
_ = source
|
||||
```
|
||||
|
||||
Construire les options d'update :
|
||||
|
||||
```go
|
||||
opts, err := mcpgen.UpdateOptions(version, os.Stdout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Ouvrir le secret store configuré par le manifeste :
|
||||
|
||||
```go
|
||||
store, err := mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{
|
||||
LookupEnv: os.LookupEnv,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = store
|
||||
```
|
||||
|
||||
Après génération, un simple `go build ./...` suffit. La compilation ne dépend
|
||||
pas de la commande `mcp-framework`.
|
||||
48
docs/getting-started.md
Normal file
48
docs/getting-started.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Installation et utilisation type
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get forge.lclr.dev/AI/mcp-framework
|
||||
```
|
||||
|
||||
## CLI de scaffold
|
||||
|
||||
Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go :
|
||||
|
||||
```bash
|
||||
go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
||||
mcp-framework scaffold init \
|
||||
--target ./my-mcp \
|
||||
--module example.com/my-mcp \
|
||||
--binary my-mcp \
|
||||
--profiles dev,prod
|
||||
```
|
||||
|
||||
Puis dans le projet généré :
|
||||
|
||||
```bash
|
||||
cd my-mcp
|
||||
go mod tidy
|
||||
go run ./cmd/my-mcp help
|
||||
```
|
||||
|
||||
## Utilisation type
|
||||
|
||||
Un flux complet côté application :
|
||||
|
||||
1. Déclarer `mcp.toml` à la racine du module.
|
||||
2. Lancer `mcp-framework generate` pour produire le package `mcpgen`.
|
||||
3. Déclarer les sous-commandes communes via `bootstrap` si l'application utilise le bootstrap CLI.
|
||||
4. Résoudre le profil actif avec `cli`.
|
||||
5. Charger la config versionnée avec `config`.
|
||||
6. Lire les secrets avec `secretstore` ou `mcpgen.OpenSecretStore`.
|
||||
7. Charger le manifest runtime avec `mcpgen.LoadManifest`.
|
||||
8. Exécuter l'auto-update avec `mcpgen.RunUpdate` ou `update.Run`.
|
||||
9. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier.
|
||||
|
||||
Pour vérifier que le code généré est synchronisé avec le manifeste :
|
||||
|
||||
```bash
|
||||
mcp-framework generate --check
|
||||
```
|
||||
3
docs/limitations.md
Normal file
3
docs/limitations.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Limites
|
||||
|
||||
- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes
|
||||
133
docs/manifest.md
Normal file
133
docs/manifest.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Manifeste `mcp.toml`
|
||||
|
||||
Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire courant puis remonte les répertoires parents jusqu'à trouver le fichier.
|
||||
Pour un binaire installé (par exemple dans `~/.local/bin`), il peut aussi charger un fallback embarqué via `LoadDefaultOrEmbedded`.
|
||||
|
||||
Exemple minimal :
|
||||
|
||||
```toml
|
||||
binary_name = "my-mcp"
|
||||
docs_url = "https://docs.example.com/my-mcp"
|
||||
|
||||
[update]
|
||||
source_name = "Gitea releases"
|
||||
driver = "gitea"
|
||||
repository = "org/repo"
|
||||
base_url = "https://gitea.example.com"
|
||||
asset_name_template = "{binary}-{os}-{arch}{ext}"
|
||||
checksum_asset_name = "{asset}.sha256"
|
||||
checksum_required = true
|
||||
signature_asset_name = "{asset}.sig"
|
||||
signature_required = false
|
||||
signature_public_key_env_names = ["MY_MCP_RELEASE_ED25519_PUBLIC_KEY"]
|
||||
token_header = "Authorization"
|
||||
token_prefix = "token"
|
||||
token_env_names = ["GITEA_TOKEN"]
|
||||
|
||||
[environment]
|
||||
known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"]
|
||||
|
||||
[secret_store]
|
||||
backend_policy = "auto"
|
||||
# Optionnel : mettre false pour désactiver le cache Bitwarden.
|
||||
bitwarden_cache = true
|
||||
|
||||
[profiles]
|
||||
default = "prod"
|
||||
known = ["dev", "staging", "prod"]
|
||||
|
||||
[bootstrap]
|
||||
description = "Client MCP interne"
|
||||
|
||||
[[config.fields]]
|
||||
name = "base_url"
|
||||
flag = "base-url"
|
||||
env = "MY_MCP_URL"
|
||||
config_key = "base_url"
|
||||
type = "url"
|
||||
label = "Base URL"
|
||||
required = true
|
||||
sources = ["flag", "env", "config"]
|
||||
|
||||
[[config.fields]]
|
||||
name = "api_token"
|
||||
flag = "api-token"
|
||||
env = "MY_MCP_TOKEN"
|
||||
secret_key_template = "profile/{profile}/api-token"
|
||||
type = "secret"
|
||||
label = "API token"
|
||||
required = true
|
||||
sources = ["flag", "env", "secret"]
|
||||
```
|
||||
|
||||
Champs supportés :
|
||||
|
||||
- `binary_name` : nom du binaire (utilisable par le bootstrap/scaffolding).
|
||||
- `docs_url` : URL de documentation projet.
|
||||
- `[update]` : source de release consommée par `update`.
|
||||
- `source_name` : nom humain de la source de release, utilisé dans certains messages d'erreur.
|
||||
- `driver` : driver de forge (`gitea`, `gitlab`, `github`) pour déduire automatiquement l'endpoint latest.
|
||||
- `repository` : dépôt cible (`org/repo` ou `group/subgroup/repo`).
|
||||
- `base_url` : base de la forge ou du service de release.
|
||||
- `latest_release_url` : URL complète qui retourne la release la plus récente (prioritaire sur le driver).
|
||||
- `asset_name_template` : template de nom d'asset (`{binary}`, `{os}`, `{arch}`, `{ext}`).
|
||||
- `checksum_asset_name` : nom d'asset checksum, avec placeholder optionnel `{asset}`.
|
||||
- `checksum_required` : si `true`, l'update échoue quand l'asset checksum est absent.
|
||||
- `signature_asset_name` : nom d'asset signature Ed25519 (détachée), avec placeholder optionnel `{asset}`.
|
||||
- `signature_required` : si `true`, l'update échoue si la signature ou la clé publique manquent, ou si la signature est invalide.
|
||||
- `signature_public_key` : clé publique Ed25519 (hex ou base64) utilisée pour vérifier la signature.
|
||||
- `signature_public_key_env_names` : variables d'environnement candidates contenant la clé publique Ed25519.
|
||||
- `token_header` : header HTTP à utiliser pour l'authentification.
|
||||
- `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...).
|
||||
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
|
||||
- `[environment].known` : variables d'environnement connues du projet.
|
||||
- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`, `bitwarden-cli`).
|
||||
- `[secret_store].bitwarden_cache` : active le cache Bitwarden mémoire et disque chiffré quand `backend_policy = "bitwarden-cli"`. Par défaut, le cache est activé si le champ est absent. Mettre `false` pour le désactiver.
|
||||
- `[profiles].default` : profil recommandé par défaut.
|
||||
- `[profiles].known` : profils connus du projet.
|
||||
- `[bootstrap].description` : description CLI utilisée par le bootstrap.
|
||||
- `[[config.fields]]` : champs de configuration déclaratifs consommés par
|
||||
`mcp-framework generate`.
|
||||
- `name` : identifiant stable du champ.
|
||||
- `flag` : nom du flag CLI, sans `--`.
|
||||
- `env` : variable d'environnement associée.
|
||||
- `config_key` : clé dans la config fichier du projet.
|
||||
- `secret_key_template` : clé de secret, avec `{profile}` remplacé par le
|
||||
profil courant dans le code généré.
|
||||
- `type` : type de setup (`string`, `url`, `secret`, `bool`, `list`).
|
||||
- `label` : libellé humain utilisé pendant le setup.
|
||||
- `default` : valeur par défaut optionnelle.
|
||||
- `required` : si `true`, la résolution échoue quand aucune source ne fournit
|
||||
de valeur.
|
||||
- `sources` : ordre de résolution spécifique au champ (`flag`, `env`,
|
||||
`config`, `secret`).
|
||||
|
||||
Toutes ces sections (hors `[update]` selon les besoins de l'application) sont optionnelles.
|
||||
|
||||
Exemple de chargement :
|
||||
|
||||
```go
|
||||
file, path, err := manifest.LoadDefault(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("manifest loaded from %s\n", path)
|
||||
source := file.Update.ReleaseSource()
|
||||
bootstrapInfo := file.BootstrapInfo()
|
||||
scaffoldInfo := file.ScaffoldInfo()
|
||||
_ = bootstrapInfo
|
||||
_ = scaffoldInfo
|
||||
_ = source
|
||||
```
|
||||
|
||||
Fallback fichier puis embarqué :
|
||||
|
||||
```go
|
||||
file, source, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("manifest source: %s\n", source) // chemin du fichier ou "embedded:mcp.toml"
|
||||
```
|
||||
63
docs/minimal-example.md
Normal file
63
docs/minimal-example.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Exemple minimal
|
||||
|
||||
Cet exemple suppose qu'un `mcp.toml` existe à la racine du module et que le
|
||||
package généré est à jour :
|
||||
|
||||
```bash
|
||||
mcp-framework generate
|
||||
```
|
||||
|
||||
Exemple de runner Go :
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"example.com/my-mcp/mcpgen"
|
||||
"forge.lclr.dev/AI/mcp-framework/config"
|
||||
"forge.lclr.dev/AI/mcp-framework/update"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
type Profile struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(context.Background()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context) error {
|
||||
cfgStore := config.NewStore[Profile](mcpgen.BinaryName)
|
||||
|
||||
cfg, _, err := cfgStore.LoadDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, source, err := mcpgen.LoadManifest(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("manifest:", source)
|
||||
|
||||
updateOptions, err := mcpgen.UpdateOptions(version, os.Stdout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := update.Run(ctx, updateOptions); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = cfg
|
||||
return nil
|
||||
}
|
||||
```
|
||||
10
docs/packages.md
Normal file
10
docs/packages.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Packages
|
||||
|
||||
- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `login`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites.
|
||||
- `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`.
|
||||
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
|
||||
- `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding.
|
||||
- `generate` : génération de code Go depuis `mcp.toml` (`mcpgen/manifest.go`, metadata, update, secret store, config fields).
|
||||
- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage).
|
||||
- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helpers runtime `OpenFromManifest`, `DescribeRuntime`, `PreflightFromManifest` et formatage homogène via `FormatBackendStatus`.
|
||||
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
|
||||
26
docs/scaffolding.md
Normal file
26
docs/scaffolding.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Scaffolding
|
||||
|
||||
Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP :
|
||||
|
||||
- arborescence recommandée (`cmd/<binary>/main.go`, `internal/app/app.go`, `mcp.toml`)
|
||||
- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI, setup local et export JSON MCP
|
||||
- wiring initial `bootstrap + config + secretstore + update`
|
||||
- `README.md` de démarrage
|
||||
|
||||
Exemple :
|
||||
|
||||
```go
|
||||
result, err := scaffold.Generate(scaffold.Options{
|
||||
TargetDir: "./my-mcp",
|
||||
ModulePath: "forge.lclr.dev/AI/my-mcp",
|
||||
BinaryName: "my-mcp",
|
||||
Description: "Client MCP interne",
|
||||
DefaultProfile: "prod",
|
||||
Profiles: []string{"dev", "prod"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Scaffold generated in %s (%d files)\n", result.Root, len(result.Files))
|
||||
```
|
||||
273
docs/secrets.md
Normal file
273
docs/secrets.md
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
# Secrets
|
||||
|
||||
Le package `secretstore` supporte plusieurs politiques de backend :
|
||||
|
||||
- `auto` : comportement par défaut, utilise un backend keyring disponible et peut retomber sur l'environnement si `LookupEnv` est fourni
|
||||
- `kwallet-only` : impose KWallet et retourne une erreur explicite si KWallet n'est pas disponible
|
||||
- `keyring-any` : impose l'utilisation d'un backend keyring disponible
|
||||
- `env-only` : lecture seule depuis les variables d'environnement
|
||||
- `bitwarden-cli` : utilise le CLI Bitwarden (`bw`) comme backend de vault
|
||||
|
||||
Backends keyring typiques :
|
||||
|
||||
- macOS : Keychain
|
||||
- Linux : Secret Service ou KWallet selon l'environnement
|
||||
- Windows : Credential Manager
|
||||
|
||||
Ouverture recommandée depuis la policy du manifeste runtime (`mcp.toml` résolu près de l'exécutable) :
|
||||
|
||||
```go
|
||||
store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||
ServiceName: "my-mcp",
|
||||
LookupEnv: os.LookupEnv,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Exemple bas niveau :
|
||||
|
||||
```go
|
||||
store, err := secretstore.Open(secretstore.Options{
|
||||
ServiceName: "my-mcp",
|
||||
BackendPolicy: secretstore.BackendAuto,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := store.SetSecret("api-token", "My MCP API token", token); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err = store.GetSecret("api-token")
|
||||
switch {
|
||||
case err == nil:
|
||||
// secret found
|
||||
case errors.Is(err, secretstore.ErrNotFound):
|
||||
// first run
|
||||
default:
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Pour imposer KWallet sur Linux :
|
||||
|
||||
```go
|
||||
store, err := secretstore.Open(secretstore.Options{
|
||||
ServiceName: "my-mcp",
|
||||
BackendPolicy: secretstore.BackendKWalletOnly,
|
||||
})
|
||||
```
|
||||
|
||||
Pour imposer Bitwarden via son CLI :
|
||||
|
||||
```go
|
||||
store, err := secretstore.Open(secretstore.Options{
|
||||
ServiceName: "my-mcp",
|
||||
BackendPolicy: secretstore.BackendBitwardenCLI,
|
||||
// Optionnel si `bw` n'est pas dans le PATH :
|
||||
// BitwardenCommand: "/usr/local/bin/bw",
|
||||
})
|
||||
```
|
||||
|
||||
## Cache Bitwarden
|
||||
|
||||
Le backend `bitwarden-cli` met en cache les lectures de secrets par défaut.
|
||||
Le cache mémoire évite les appels répétés au CLI dans un même process. Le cache
|
||||
disque est chiffré avec une clé dérivée de `BW_SESSION` via HKDF-SHA256 et
|
||||
AES-GCM.
|
||||
|
||||
TTL par défaut : 10 minutes.
|
||||
|
||||
Pour désactiver le cache dans `mcp.toml` :
|
||||
|
||||
```toml
|
||||
[secret_store]
|
||||
backend_policy = "bitwarden-cli"
|
||||
bitwarden_cache = false
|
||||
```
|
||||
|
||||
Pour le désactiver sans modifier le manifeste :
|
||||
|
||||
```bash
|
||||
MCP_FRAMEWORK_BITWARDEN_CACHE=0
|
||||
```
|
||||
|
||||
Le fichier de cache et le binaire installé ne suffisent pas à déchiffrer les
|
||||
secrets. Si `BW_SESSION` change ou disparaît, les entrées disque existantes
|
||||
deviennent inutilisables. Cette protection ne couvre pas un attaquant qui peut
|
||||
lire l'environnement ou la mémoire du process pendant l'exécution.
|
||||
|
||||
Pour vérifier explicitement que Bitwarden est prêt (login + unlock + `BW_SESSION`) :
|
||||
|
||||
```go
|
||||
if err := secretstore.EnsureBitwardenReady(secretstore.Options{
|
||||
BitwardenCommand: "bw",
|
||||
LookupEnv: os.LookupEnv,
|
||||
}); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, secretstore.ErrBWNotLoggedIn):
|
||||
// guider vers `bw login`
|
||||
case errors.Is(err, secretstore.ErrBWLocked):
|
||||
// guider vers `bw unlock --raw` puis export BW_SESSION
|
||||
default:
|
||||
// indisponibilité CLI/réseau
|
||||
}
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Pour lancer un flux interactif `bw login` / `bw unlock --raw`, récupérer `BW_SESSION`
|
||||
et le persister localement (fichier `0600` sous le répertoire de config utilisateur) :
|
||||
|
||||
```go
|
||||
session, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{
|
||||
ServiceName: "my-mcp",
|
||||
Stdin: os.Stdin,
|
||||
Stdout: os.Stdout,
|
||||
Stderr: os.Stderr,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("session chargée:", len(session) > 0)
|
||||
```
|
||||
|
||||
Pour réinjecter automatiquement une session persistée dans l'environnement courant :
|
||||
|
||||
```go
|
||||
loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{
|
||||
ServiceName: "my-mcp",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("session restaurée depuis disque:", loaded)
|
||||
```
|
||||
|
||||
Pour stocker un secret structuré en JSON :
|
||||
|
||||
```go
|
||||
type Credentials struct {
|
||||
Host string `json:"host"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
err = secretstore.SetJSON(store, "imap-credentials", "IMAP credentials", Credentials{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: token,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
creds, err := secretstore.GetJSON[Credentials](store, "imap-credentials")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = creds
|
||||
```
|
||||
|
||||
Pour écrire puis confirmer immédiatement une relecture :
|
||||
|
||||
```go
|
||||
if err := secretstore.SetSecretVerified(store, "api-token", "My MCP API token", token); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Pour connaître le backend effectif utilisé :
|
||||
|
||||
```go
|
||||
effective := secretstore.EffectiveBackendPolicy(store)
|
||||
fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any...
|
||||
```
|
||||
|
||||
Pour obtenir en un seul appel une description runtime légère (source manifeste,
|
||||
policy déclarée/effective, backend affiché) :
|
||||
|
||||
```go
|
||||
desc, err := secretstore.DescribeRuntime(secretstore.DescribeRuntimeOptions{
|
||||
ServiceName: "my-mcp",
|
||||
LookupEnv: os.LookupEnv,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(secretstore.FormatBackendStatus(desc))
|
||||
// declared=... effective=... display=... ready=... source=...
|
||||
```
|
||||
|
||||
`DescribeRuntime` ne contacte pas Bitwarden par défaut. Pour vérifier réellement
|
||||
la disponibilité du backend, utiliser le préflight :
|
||||
|
||||
```go
|
||||
report, err := secretstore.PreflightFromManifest(secretstore.PreflightOptions{
|
||||
ServiceName: "my-mcp",
|
||||
LookupEnv: os.LookupEnv,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(report.Status) // ready | fail
|
||||
fmt.Println(report.Summary) // message court
|
||||
fmt.Println(report.Remediation) // action recommandée
|
||||
```
|
||||
|
||||
## Debug Bitwarden en 60 secondes
|
||||
|
||||
Tu peux activer les traces d'appels Bitwarden avec le flag CLI global `--debug`
|
||||
(via `bootstrap`) ou en exportant `MCP_FRAMEWORK_BITWARDEN_DEBUG=1`.
|
||||
Les commandes `bw` exécutées seront affichées (avec redaction des payloads sensibles).
|
||||
|
||||
1. Vérifier l'état de session :
|
||||
|
||||
```bash
|
||||
bw status
|
||||
```
|
||||
|
||||
2. Déverrouiller le vault et exporter `BW_SESSION` (ou utiliser `LoginBitwarden`) :
|
||||
|
||||
- Bash/Zsh :
|
||||
|
||||
```bash
|
||||
export BW_SESSION="$(bw unlock --raw)"
|
||||
```
|
||||
|
||||
- Fish :
|
||||
|
||||
```fish
|
||||
set -x BW_SESSION (bw unlock --raw)
|
||||
```
|
||||
|
||||
- PowerShell :
|
||||
|
||||
```powershell
|
||||
$env:BW_SESSION = (bw unlock --raw)
|
||||
```
|
||||
|
||||
3. Vérifier lecture/écriture rapide :
|
||||
|
||||
```go
|
||||
if err := store.SetSecret("debug-token", "Debug token", "ok"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := store.GetSecret("debug-token")
|
||||
return err
|
||||
```
|
||||
|
||||
4. Interpréter les erreurs typées :
|
||||
|
||||
- `secretstore.ErrBWNotLoggedIn` : `bw login` requis.
|
||||
- `secretstore.ErrBWLocked` : vault verrouillé ou `BW_SESSION` absent.
|
||||
- `secretstore.ErrBWUnavailable` : CLI/réseau indisponible.
|
||||
- `secretstore.ErrBackendUnavailable` : policy non satisfiable dans le contexte courant.
|
||||
|
||||
En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`.
|
||||
Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`.
|
||||
1356
docs/superpowers/plans/2026-05-02-bitwarden-cache.md
Normal file
1356
docs/superpowers/plans/2026-05-02-bitwarden-cache.md
Normal file
File diff suppressed because it is too large
Load diff
217
docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md
Normal file
217
docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# Bitwarden Cache Design
|
||||
|
||||
Date: 2026-05-02
|
||||
|
||||
## Context
|
||||
|
||||
The `secretstore` package supports a `bitwarden-cli` backend. Each secret read can call the Bitwarden CLI several times:
|
||||
|
||||
- `bw list items --search <service>/<secret>`
|
||||
- `bw get item <id>` for each candidate item
|
||||
|
||||
These calls can take several seconds when commands such as MCP `setup`, `doctor`, or generated config resolution read multiple secrets or are run repeatedly.
|
||||
|
||||
The framework already requires an unlocked Bitwarden session for this backend and restores a persisted `BW_SESSION` into the process environment when available. The cache must use that runtime session as the trust root without embedding a static key in the binary or repository.
|
||||
|
||||
## Goals
|
||||
|
||||
- Avoid repeated Bitwarden CLI calls for short-lived and long-running framework processes.
|
||||
- Keep the feature portable across Windows, macOS, Linux, and WSL.
|
||||
- Enable the encrypted disk cache by default when `BW_SESSION` is available.
|
||||
- Allow projects and operators to disable the cache.
|
||||
- Never make cached secrets decryptable with only the installed binary, cache files, and repository content.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Protect against an attacker who can read the process memory or environment while the process is running.
|
||||
- Add an OS keyring dependency for the first implementation.
|
||||
- Cache non-Bitwarden secret backends.
|
||||
- Persist decrypted secrets on disk.
|
||||
|
||||
## Configuration
|
||||
|
||||
`manifest.SecretStore` gains:
|
||||
|
||||
```toml
|
||||
[secret_store]
|
||||
backend_policy = "bitwarden-cli"
|
||||
bitwarden_cache = true
|
||||
```
|
||||
|
||||
`bitwarden_cache` defaults to `true` when omitted.
|
||||
|
||||
The generated helper options continue to flow through `OpenFromManifest` and `DescribeRuntime`. The runtime option is represented in `secretstore.Options` and `secretstore.OpenFromManifestOptions` so generated packages can still override it programmatically if needed.
|
||||
|
||||
An environment variable can force-disable the cache without editing `mcp.toml`:
|
||||
|
||||
```text
|
||||
MCP_FRAMEWORK_BITWARDEN_CACHE=0
|
||||
```
|
||||
|
||||
Accepted false values are `0`, `false`, `no`, `off`, and `disabled`, case-insensitive. Any other value leaves the manifest/default behavior in place.
|
||||
|
||||
## Architecture
|
||||
|
||||
The cache is internal to the `bitwarden-cli` backend.
|
||||
|
||||
`bitwardenStore.GetSecret(name)` resolves secrets in this order:
|
||||
|
||||
1. In-memory cache.
|
||||
2. Encrypted disk cache, only if a cache key can be derived from the effective `BW_SESSION`.
|
||||
3. Bitwarden CLI lookup.
|
||||
|
||||
After a successful CLI lookup, the secret is written to the memory cache and, when available, the encrypted disk cache.
|
||||
|
||||
`SetSecret` and `DeleteSecret` update Bitwarden first. After success, they invalidate the cache entry for the affected secret. `SetSecret` may repopulate the memory and disk cache with the new value after the Bitwarden write succeeds, but it must not expose a value that failed to persist to Bitwarden.
|
||||
|
||||
## Cache Contents
|
||||
|
||||
Memory entries store:
|
||||
|
||||
- secret value
|
||||
- expiration timestamp
|
||||
|
||||
Disk entries store encrypted JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"service_name": "graylog-mcp",
|
||||
"secret_name": "profile/prod/api-token",
|
||||
"scoped_name": "graylog-mcp/profile/prod/api-token",
|
||||
"created_at": "2026-05-02T10:00:00Z",
|
||||
"expires_at": "2026-05-02T10:10:00Z",
|
||||
"value": "secret"
|
||||
}
|
||||
```
|
||||
|
||||
The plaintext exists only before encryption and after decryption in memory.
|
||||
|
||||
## Key Derivation
|
||||
|
||||
Disk cache is enabled only when the effective `BW_SESSION` is non-empty. The effective session is the value available after `EnsureBitwardenSessionEnv`, so it can come from either the process environment or the framework-restored session file.
|
||||
|
||||
The cache derives dedicated keys with HKDF-SHA256:
|
||||
|
||||
```text
|
||||
master_key = HKDF-SHA256(
|
||||
input key material: BW_SESSION,
|
||||
salt: "mcp-framework bitwarden cache salt v1",
|
||||
info: "mcp-framework bitwarden cache v1"
|
||||
)
|
||||
```
|
||||
|
||||
Separate subkeys are derived from `master_key`:
|
||||
|
||||
- encryption key: `info = "mcp-framework bitwarden cache encryption v1"`
|
||||
- entry ID key: `info = "mcp-framework bitwarden cache entry id v1"`
|
||||
|
||||
`BW_SESSION` is never used directly as an AES key.
|
||||
|
||||
## Disk Encryption
|
||||
|
||||
Disk entries use AES-256-GCM from the Go standard library.
|
||||
|
||||
Each write generates a fresh random nonce with `crypto/rand`. The file format is JSON with metadata needed to decrypt:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"algorithm": "AES-256-GCM",
|
||||
"nonce": "<base64>",
|
||||
"ciphertext": "<base64>"
|
||||
}
|
||||
```
|
||||
|
||||
Authenticated additional data includes stable, non-secret cache context:
|
||||
|
||||
```text
|
||||
mcp-framework bitwarden cache v1
|
||||
service=<serviceName>
|
||||
secret=<secretName>
|
||||
scoped=<serviceName>/<secretName>
|
||||
```
|
||||
|
||||
If decryption fails, the entry is treated as a cache miss. The framework may remove the unusable file as best effort.
|
||||
|
||||
## Entry Identity
|
||||
|
||||
Disk file names do not expose secret names or Bitwarden item refs. The file name is:
|
||||
|
||||
```text
|
||||
hex(HMAC-SHA256(entry_id_key, cache_context)) + ".json"
|
||||
```
|
||||
|
||||
The cache context includes:
|
||||
|
||||
- cache format version
|
||||
- service name
|
||||
- raw secret name
|
||||
- scoped Bitwarden item name
|
||||
- backend scope marker
|
||||
|
||||
Because the HMAC key is derived from `BW_SESSION`, changing or losing `BW_SESSION` makes existing file names undiscoverable and existing entries undecryptable.
|
||||
|
||||
## TTL and Invalidation
|
||||
|
||||
Default TTL: 10 minutes.
|
||||
|
||||
TTL applies to both memory and disk entries. Expired entries are treated as misses and may be deleted best-effort.
|
||||
|
||||
The first implementation uses a constant default TTL. It keeps room for a future `bitwarden_cache_ttl` option, but does not add that option now to keep scope tight.
|
||||
|
||||
Invalidation rules:
|
||||
|
||||
- `SetSecret` invalidates the affected entry after the Bitwarden write succeeds.
|
||||
- `DeleteSecret` invalidates the affected entry after the Bitwarden delete succeeds or confirms the item is already absent.
|
||||
- `BW_SESSION` change implicitly invalidates disk cache through key derivation.
|
||||
- `bitwarden_cache = false` disables both memory and disk cache for the backend instance.
|
||||
- `MCP_FRAMEWORK_BITWARDEN_CACHE=0` disables both memory and disk cache.
|
||||
|
||||
## Storage Location and Permissions
|
||||
|
||||
Disk cache path:
|
||||
|
||||
```text
|
||||
os.UserCacheDir()/serviceName/bitwarden-cache
|
||||
```
|
||||
|
||||
If `os.UserCacheDir` fails, disk cache is disabled and secret reads continue through memory cache and Bitwarden CLI.
|
||||
|
||||
The cache directory is created with `0700` and cache files with `0600` where the platform supports Unix-style permissions. Permission setting errors disable disk cache for that operation rather than failing secret resolution, because Bitwarden remains the source of truth.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Cache failures must not make a healthy Bitwarden backend unusable.
|
||||
|
||||
- Memory cache errors are not expected.
|
||||
- Disk cache read/decrypt/parse errors are treated as misses.
|
||||
- Disk cache write errors are ignored after optional debug logging.
|
||||
- Bitwarden CLI errors keep their current behavior and typed error classification.
|
||||
- Malformed or expired cache entries are never returned.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests should cover:
|
||||
|
||||
- repeated `GetSecret` hits memory and avoids a second CLI item read
|
||||
- reopened store can read from encrypted disk cache without calling `bw get item`
|
||||
- disk cache file does not contain the secret value or clear secret name
|
||||
- cache is disabled by `bitwarden_cache = false`
|
||||
- cache is disabled by `MCP_FRAMEWORK_BITWARDEN_CACHE=0`
|
||||
- expired entries are missed and refreshed from Bitwarden
|
||||
- `SetSecret` invalidates or refreshes stale cache data
|
||||
- `DeleteSecret` removes cached data
|
||||
- missing `BW_SESSION` disables disk cache
|
||||
- changed `BW_SESSION` cannot decrypt a previous disk entry
|
||||
- manifest parsing preserves the default enabled behavior when the field is omitted
|
||||
|
||||
## Documentation
|
||||
|
||||
Update:
|
||||
|
||||
- `docs/manifest.md` for `[secret_store].bitwarden_cache`
|
||||
- `docs/secrets.md` for cache behavior, TTL, disable controls, and threat model
|
||||
- generated/scaffolded `mcp.toml` examples only if the option should be visible by default
|
||||
|
||||
The preferred scaffold output should omit `bitwarden_cache` because the default is enabled. Documentation should show it as the explicit disable knob.
|
||||
617
generate/generate.go
Normal file
617
generate/generate.go
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
package generate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
var ErrGeneratedFilesOutdated = errors.New("generated files are not up to date")
|
||||
|
||||
type Options struct {
|
||||
ProjectDir string
|
||||
ManifestPath string
|
||||
PackageDir string
|
||||
PackageName string
|
||||
Check bool
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Root string
|
||||
Files []string
|
||||
}
|
||||
|
||||
func Generate(options Options) (Result, error) {
|
||||
normalized, err := normalizeOptions(options)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
manifestFile, err := manifest.Load(normalized.ManifestPath)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
manifestContent, err := os.ReadFile(normalized.ManifestPath)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("read manifest %s: %w", normalized.ManifestPath, err)
|
||||
}
|
||||
|
||||
manifestLoader, err := renderManifestLoader(normalized.PackageName, string(manifestContent))
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
metadata, err := renderMetadata(normalized.PackageName, manifestFile)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
update, err := renderUpdate(normalized.PackageName)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
secretstore, err := renderSecretStore(normalized.PackageName)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
config, err := renderConfig(normalized.PackageName, manifestFile.Config.Fields)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
files := []generatedFile{
|
||||
{
|
||||
Path: filepath.Join(normalized.PackageDir, "manifest.go"),
|
||||
Content: manifestLoader,
|
||||
Mode: 0o644,
|
||||
},
|
||||
{
|
||||
Path: filepath.Join(normalized.PackageDir, "metadata.go"),
|
||||
Content: metadata,
|
||||
Mode: 0o644,
|
||||
},
|
||||
{
|
||||
Path: filepath.Join(normalized.PackageDir, "update.go"),
|
||||
Content: update,
|
||||
Mode: 0o644,
|
||||
},
|
||||
{
|
||||
Path: filepath.Join(normalized.PackageDir, "secretstore.go"),
|
||||
Content: secretstore,
|
||||
Mode: 0o644,
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(config) != "" {
|
||||
files = append(files, generatedFile{
|
||||
Path: filepath.Join(normalized.PackageDir, "config.go"),
|
||||
Content: config,
|
||||
Mode: 0o644,
|
||||
})
|
||||
}
|
||||
|
||||
written := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
target := filepath.Join(normalized.ProjectDir, file.Path)
|
||||
if normalized.Check {
|
||||
current, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path)
|
||||
}
|
||||
return Result{}, fmt.Errorf("read generated file %q: %w", target, err)
|
||||
}
|
||||
if !bytes.Equal(current, []byte(file.Content)) {
|
||||
return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path)
|
||||
}
|
||||
written = append(written, file.Path)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := writeGeneratedFile(target, file.Content, file.Mode); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
written = append(written, file.Path)
|
||||
}
|
||||
|
||||
sort.Strings(written)
|
||||
return Result{
|
||||
Root: normalized.ProjectDir,
|
||||
Files: written,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type normalizedOptions struct {
|
||||
ProjectDir string
|
||||
ManifestPath string
|
||||
PackageDir string
|
||||
PackageName string
|
||||
Check bool
|
||||
}
|
||||
|
||||
type generatedFile struct {
|
||||
Path string
|
||||
Content string
|
||||
Mode os.FileMode
|
||||
}
|
||||
|
||||
func normalizeOptions(options Options) (normalizedOptions, error) {
|
||||
manifestPath := strings.TrimSpace(options.ManifestPath)
|
||||
projectDir := strings.TrimSpace(options.ProjectDir)
|
||||
|
||||
if manifestPath == "" {
|
||||
baseDir := projectDir
|
||||
if baseDir == "" {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err)
|
||||
}
|
||||
baseDir = wd
|
||||
}
|
||||
manifestPath = filepath.Join(baseDir, manifest.DefaultFile)
|
||||
} else if !filepath.IsAbs(manifestPath) {
|
||||
baseDir := projectDir
|
||||
if baseDir == "" {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err)
|
||||
}
|
||||
baseDir = wd
|
||||
}
|
||||
manifestPath = filepath.Join(baseDir, manifestPath)
|
||||
}
|
||||
|
||||
resolvedManifest, err := filepath.Abs(manifestPath)
|
||||
if err != nil {
|
||||
return normalizedOptions{}, fmt.Errorf("resolve manifest path %q: %w", manifestPath, err)
|
||||
}
|
||||
|
||||
if projectDir == "" {
|
||||
projectDir = filepath.Dir(resolvedManifest)
|
||||
}
|
||||
resolvedProjectDir, err := filepath.Abs(projectDir)
|
||||
if err != nil {
|
||||
return normalizedOptions{}, fmt.Errorf("resolve project dir %q: %w", projectDir, err)
|
||||
}
|
||||
|
||||
packageDir := filepath.Clean(strings.TrimSpace(options.PackageDir))
|
||||
if packageDir == "." || packageDir == "" {
|
||||
packageDir = "mcpgen"
|
||||
}
|
||||
if filepath.IsAbs(packageDir) || packageDir == ".." || strings.HasPrefix(packageDir, ".."+string(filepath.Separator)) {
|
||||
return normalizedOptions{}, fmt.Errorf("package dir %q must be relative to the project", options.PackageDir)
|
||||
}
|
||||
|
||||
packageName := strings.TrimSpace(options.PackageName)
|
||||
if packageName == "" {
|
||||
packageName = filepath.Base(packageDir)
|
||||
}
|
||||
if !token.IsIdentifier(packageName) {
|
||||
return normalizedOptions{}, fmt.Errorf("package name %q is not a valid Go identifier", packageName)
|
||||
}
|
||||
|
||||
return normalizedOptions{
|
||||
ProjectDir: resolvedProjectDir,
|
||||
ManifestPath: resolvedManifest,
|
||||
PackageDir: packageDir,
|
||||
PackageName: packageName,
|
||||
Check: options.Check,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func renderManifestLoader(packageName, manifestContent string) (string, error) {
|
||||
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package %s
|
||||
|
||||
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
|
||||
const embeddedManifest = %s
|
||||
|
||||
func LoadManifest(startDir string) (fwmanifest.File, string, error) {
|
||||
return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)
|
||||
}
|
||||
`, packageName, strconv.Quote(manifestContent))
|
||||
|
||||
formatted, err := format.Source([]byte(source))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("format generated manifest loader: %w", err)
|
||||
}
|
||||
|
||||
return string(formatted), nil
|
||||
}
|
||||
|
||||
func renderMetadata(packageName string, manifestFile manifest.File) (string, error) {
|
||||
bootstrapInfo := manifestFile.BootstrapInfo()
|
||||
|
||||
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package %s
|
||||
|
||||
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
|
||||
const BinaryName = %s
|
||||
const DefaultDescription = %s
|
||||
const DocsURL = %s
|
||||
|
||||
func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) {
|
||||
manifestFile, source, err := LoadManifest(startDir)
|
||||
if err != nil {
|
||||
return fwmanifest.BootstrapMetadata{}, "", err
|
||||
}
|
||||
|
||||
return manifestFile.BootstrapInfo(), source, nil
|
||||
}
|
||||
|
||||
func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) {
|
||||
manifestFile, source, err := LoadManifest(startDir)
|
||||
if err != nil {
|
||||
return fwmanifest.ScaffoldMetadata{}, "", err
|
||||
}
|
||||
|
||||
return manifestFile.ScaffoldInfo(), source, nil
|
||||
}
|
||||
`, packageName, strconv.Quote(manifestFile.BinaryName), strconv.Quote(bootstrapInfo.Description), strconv.Quote(manifestFile.DocsURL))
|
||||
|
||||
return formatGenerated("metadata", source)
|
||||
}
|
||||
|
||||
func renderUpdate(packageName string) (string, error) {
|
||||
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package %s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
fwupdate "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)
|
||||
}
|
||||
`, packageName)
|
||||
|
||||
return formatGenerated("update", source)
|
||||
}
|
||||
|
||||
func renderSecretStore(packageName string) (string, error) {
|
||||
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package %s
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
fwsecretstore "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
|
||||
}
|
||||
`, packageName)
|
||||
|
||||
return formatGenerated("secretstore", source)
|
||||
}
|
||||
|
||||
func renderConfig(packageName string, fields []manifest.ConfigField) (string, error) {
|
||||
if len(fields) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var flagsBuilder strings.Builder
|
||||
var specsBuilder strings.Builder
|
||||
var setupBuilder strings.Builder
|
||||
for _, field := range fields {
|
||||
name := strings.TrimSpace(field.Name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("generate config field: name must not be empty")
|
||||
}
|
||||
|
||||
flagName := strings.TrimSpace(field.Flag)
|
||||
if flagName != "" {
|
||||
fmt.Fprintf(
|
||||
&flagsBuilder,
|
||||
"\tflags.values[%s] = fs.String(%s, \"\", %s)\n",
|
||||
strconv.Quote(name),
|
||||
strconv.Quote(flagName),
|
||||
strconv.Quote(configFieldLabel(field)),
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Fprintf(
|
||||
&specsBuilder,
|
||||
"\t\t{Name: %s, Required: %t, DefaultValue: %s, Sources: []fwcli.ValueSource{%s}, FlagKey: %s, EnvKey: %s, ConfigKey: %s, SecretKey: replaceProfile(%s, profile)},\n",
|
||||
strconv.Quote(name),
|
||||
field.Required,
|
||||
strconv.Quote(field.Default),
|
||||
configSourceList(field.Sources),
|
||||
strconv.Quote(flagName),
|
||||
strconv.Quote(field.Env),
|
||||
strconv.Quote(field.ConfigKey),
|
||||
strconv.Quote(field.SecretKeyTemplate),
|
||||
)
|
||||
|
||||
fmt.Fprintf(
|
||||
&setupBuilder,
|
||||
"\t\t{Name: %s, Label: %s, Type: %s, Required: %t, Default: %s, ExistingSecret: existing[%s]},\n",
|
||||
strconv.Quote(name),
|
||||
strconv.Quote(configFieldLabel(field)),
|
||||
configSetupFieldType(field.Type),
|
||||
field.Required,
|
||||
strconv.Quote(field.Default),
|
||||
strconv.Quote(name),
|
||||
)
|
||||
}
|
||||
|
||||
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package %s
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strings"
|
||||
|
||||
fwcli "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),
|
||||
}
|
||||
%s
|
||||
return flags
|
||||
}
|
||||
|
||||
func ConfigFlagValues(flags ConfigFlags) map[string]string {
|
||||
values := make(map[string]string)
|
||||
for name, value := range flags.values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if trimmed := strings.TrimSpace(*value); trimmed != "" {
|
||||
values[name] = trimmed
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {
|
||||
return []fwcli.FieldSpec{
|
||||
%s
|
||||
}
|
||||
}
|
||||
|
||||
func SetupFields(existing map[string]string) []fwcli.SetupField {
|
||||
if existing == nil {
|
||||
existing = map[string]string{}
|
||||
}
|
||||
|
||||
return []fwcli.SetupField{
|
||||
%s
|
||||
}
|
||||
}
|
||||
|
||||
func replaceProfile(value, profile string) string {
|
||||
return strings.ReplaceAll(value, "{profile}", strings.TrimSpace(profile))
|
||||
}
|
||||
`, packageName, flagsBuilder.String(), specsBuilder.String(), setupBuilder.String())
|
||||
|
||||
return formatGenerated("config", source)
|
||||
}
|
||||
|
||||
func configFieldLabel(field manifest.ConfigField) string {
|
||||
if label := strings.TrimSpace(field.Label); label != "" {
|
||||
return label
|
||||
}
|
||||
return strings.TrimSpace(field.Name)
|
||||
}
|
||||
|
||||
func configSourceList(sources []string) string {
|
||||
if len(sources) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
switch strings.TrimSpace(source) {
|
||||
case "flag":
|
||||
parts = append(parts, "fwcli.SourceFlag")
|
||||
case "env":
|
||||
parts = append(parts, "fwcli.SourceEnv")
|
||||
case "config":
|
||||
parts = append(parts, "fwcli.SourceConfig")
|
||||
case "secret":
|
||||
parts = append(parts, "fwcli.SourceSecret")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func configSetupFieldType(fieldType string) string {
|
||||
switch strings.TrimSpace(fieldType) {
|
||||
case "url":
|
||||
return "fwcli.SetupFieldURL"
|
||||
case "secret":
|
||||
return "fwcli.SetupFieldSecret"
|
||||
case "bool":
|
||||
return "fwcli.SetupFieldBool"
|
||||
case "list":
|
||||
return "fwcli.SetupFieldList"
|
||||
default:
|
||||
return "fwcli.SetupFieldString"
|
||||
}
|
||||
}
|
||||
|
||||
func formatGenerated(name, source string) (string, error) {
|
||||
formatted, err := format.Source([]byte(source))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("format generated %s: %w", name, err)
|
||||
}
|
||||
|
||||
return string(formatted), nil
|
||||
}
|
||||
|
||||
func writeGeneratedFile(path, content string, mode os.FileMode) error {
|
||||
current, err := os.ReadFile(path)
|
||||
if err == nil && bytes.Equal(current, []byte(content)) {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("read generated file %q: %w", path, err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create generated directory %q: %w", dir, err)
|
||||
}
|
||||
|
||||
if mode == 0 {
|
||||
mode = 0o644
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
return fmt.Errorf("write generated file %q: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
499
generate/generate_test.go
Normal file
499
generate/generate_test.go
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
package generate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateCreatesManifestLoader(t *testing.T) {
|
||||
projectDir := newProject(t, `
|
||||
binary_name = "demo-mcp"
|
||||
docs_url = "https://docs.example.com/demo"
|
||||
|
||||
[bootstrap]
|
||||
description = "Demo MCP"
|
||||
`)
|
||||
|
||||
result, err := Generate(Options{ProjectDir: projectDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(result.Files, defaultGeneratedFiles("mcpgen")) {
|
||||
t.Fatalf("result files = %v", result.Files)
|
||||
}
|
||||
|
||||
generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go")
|
||||
content, err := os.ReadFile(generatedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile generated manifest: %v", err)
|
||||
}
|
||||
|
||||
for _, snippet := range []string{
|
||||
"// Code generated by mcp-framework generate. DO NOT EDIT.",
|
||||
"package mcpgen",
|
||||
"import fwmanifest \"forge.lclr.dev/AI/mcp-framework/manifest\"",
|
||||
"const embeddedManifest = ",
|
||||
"func LoadManifest(startDir string) (fwmanifest.File, string, error) {",
|
||||
"return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)",
|
||||
`binary_name = \"demo-mcp\"`,
|
||||
} {
|
||||
if !strings.Contains(string(content), snippet) {
|
||||
t.Fatalf("generated manifest.go missing snippet %q:\n%s", snippet, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCreatesP1Helpers(t *testing.T) {
|
||||
projectDir := newProject(t, `
|
||||
binary_name = "demo-mcp"
|
||||
docs_url = "https://docs.example.com/demo"
|
||||
|
||||
[update]
|
||||
driver = "gitea"
|
||||
repository = "org/demo-mcp"
|
||||
base_url = "https://gitea.example.com"
|
||||
asset_name_template = "{binary}-{os}-{arch}{ext}"
|
||||
|
||||
[secret_store]
|
||||
backend_policy = "env-only"
|
||||
|
||||
[bootstrap]
|
||||
description = "Demo MCP"
|
||||
`)
|
||||
|
||||
result, err := Generate(Options{ProjectDir: projectDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
wantFiles := []string{
|
||||
filepath.Join("mcpgen", "manifest.go"),
|
||||
filepath.Join("mcpgen", "metadata.go"),
|
||||
filepath.Join("mcpgen", "secretstore.go"),
|
||||
filepath.Join("mcpgen", "update.go"),
|
||||
}
|
||||
if !slices.Equal(result.Files, wantFiles) {
|
||||
t.Fatalf("result files = %v, want %v", result.Files, wantFiles)
|
||||
}
|
||||
|
||||
metadata, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "metadata.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile metadata.go: %v", err)
|
||||
}
|
||||
for _, snippet := range []string{
|
||||
`const BinaryName = "demo-mcp"`,
|
||||
`const DefaultDescription = "Demo MCP"`,
|
||||
`const DocsURL = "https://docs.example.com/demo"`,
|
||||
"func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) {",
|
||||
"func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) {",
|
||||
} {
|
||||
if !strings.Contains(string(metadata), snippet) {
|
||||
t.Fatalf("metadata.go missing snippet %q:\n%s", snippet, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
update, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "update.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile update.go: %v", err)
|
||||
}
|
||||
for _, snippet := range []string{
|
||||
"func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) {",
|
||||
"func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) {",
|
||||
"func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error {",
|
||||
"ReleaseSource:",
|
||||
} {
|
||||
if !strings.Contains(string(update), snippet) {
|
||||
t.Fatalf("update.go missing snippet %q:\n%s", snippet, update)
|
||||
}
|
||||
}
|
||||
|
||||
secretstore, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile secretstore.go: %v", err)
|
||||
}
|
||||
for _, snippet := range []string{
|
||||
"type SecretStoreOptions struct {",
|
||||
"func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) {",
|
||||
"func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) {",
|
||||
"func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) {",
|
||||
"ManifestLoader:",
|
||||
} {
|
||||
if !strings.Contains(string(secretstore), snippet) {
|
||||
t.Fatalf("secretstore.go missing snippet %q:\n%s", snippet, secretstore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCreatesConfigHelpersFromManifestFields(t *testing.T) {
|
||||
projectDir := newProject(t, `
|
||||
binary_name = "demo-mcp"
|
||||
|
||||
[[config.fields]]
|
||||
name = "base_url"
|
||||
flag = "base-url"
|
||||
env = "BASE_URL"
|
||||
config_key = "base_url"
|
||||
type = "url"
|
||||
label = "Graylog URL"
|
||||
required = true
|
||||
sources = ["flag", "env", "config"]
|
||||
|
||||
[[config.fields]]
|
||||
name = "api_token"
|
||||
flag = "api-token"
|
||||
env = "API_TOKEN"
|
||||
secret_key_template = "profile/{profile}/api-token"
|
||||
type = "secret"
|
||||
label = "API token"
|
||||
required = true
|
||||
sources = ["flag", "env", "secret"]
|
||||
`)
|
||||
|
||||
result, err := Generate(Options{ProjectDir: projectDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
wantFiles := generatedFilesWithConfig("mcpgen")
|
||||
if !slices.Equal(result.Files, wantFiles) {
|
||||
t.Fatalf("result files = %v, want %v", result.Files, wantFiles)
|
||||
}
|
||||
|
||||
config, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "config.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile config.go: %v", err)
|
||||
}
|
||||
for _, snippet := range []string{
|
||||
"type ConfigFlags struct {",
|
||||
"func AddConfigFlags(fs *flag.FlagSet) ConfigFlags {",
|
||||
"func ConfigFlagValues(flags ConfigFlags) map[string]string {",
|
||||
"func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {",
|
||||
"func SetupFields(existing map[string]string) []fwcli.SetupField {",
|
||||
`fs.String("base-url", "", "Graylog URL")`,
|
||||
`SecretKey: replaceProfile("profile/{profile}/api-token", profile)`,
|
||||
"fwcli.SetupFieldURL",
|
||||
"fwcli.SetupFieldSecret",
|
||||
} {
|
||||
if !strings.Contains(string(config), snippet) {
|
||||
t.Fatalf("config.go missing snippet %q:\n%s", snippet, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) {
|
||||
projectDir := newProject(t, `binary_name = "demo-mcp"`)
|
||||
|
||||
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
|
||||
t.Fatalf("first Generate returned error: %v", err)
|
||||
}
|
||||
generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go")
|
||||
first, err := os.ReadFile(generatedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile first generated file: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
|
||||
t.Fatalf("second Generate returned error: %v", err)
|
||||
}
|
||||
second, err := os.ReadFile(generatedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile second generated file: %v", err)
|
||||
}
|
||||
if string(second) != string(first) {
|
||||
t.Fatalf("second generation changed content")
|
||||
}
|
||||
|
||||
if _, err := Generate(Options{ProjectDir: projectDir, Check: true}); err != nil {
|
||||
t.Fatalf("check after generation returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(generatedPath, append(second, []byte("// drift\n")...), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile drift: %v", err)
|
||||
}
|
||||
|
||||
_, err = Generate(Options{ProjectDir: projectDir, Check: true})
|
||||
if !errors.Is(err, ErrGeneratedFilesOutdated) {
|
||||
t.Fatalf("check error = %v, want ErrGeneratedFilesOutdated", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSupportsManifestAndPackageFlags(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
manifestPath := filepath.Join(projectDir, "config", "custom.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll manifest dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(manifestPath, []byte(`binary_name = "demo-mcp"`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
result, err := Generate(Options{
|
||||
ProjectDir: projectDir,
|
||||
ManifestPath: manifestPath,
|
||||
PackageDir: "internal/generated",
|
||||
PackageName: "generated",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(result.Files, defaultGeneratedFiles(filepath.Join("internal", "generated"))) {
|
||||
t.Fatalf("result files = %v", result.Files)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(projectDir, "internal", "generated", "manifest.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile generated manifest: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "package generated") {
|
||||
t.Fatalf("generated file should use package name: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsInvalidManifest(t *testing.T) {
|
||||
projectDir := newProject(t, "[bootstrap\n")
|
||||
|
||||
_, err := Generate(Options{ProjectDir: projectDir})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parse manifest") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedLoaderFallsBackToEmbeddedManifest(t *testing.T) {
|
||||
projectDir := newProject(t, `
|
||||
binary_name = "embedded-demo"
|
||||
docs_url = "https://docs.example.com/embedded"
|
||||
|
||||
[update]
|
||||
driver = "gitea"
|
||||
repository = "org/embedded-demo"
|
||||
base_url = "https://gitea.example.com"
|
||||
|
||||
[secret_store]
|
||||
backend_policy = "env-only"
|
||||
|
||||
[bootstrap]
|
||||
description = "Embedded Demo"
|
||||
|
||||
[[config.fields]]
|
||||
name = "base_url"
|
||||
flag = "base-url"
|
||||
env = "BASE_URL"
|
||||
config_key = "base_url"
|
||||
type = "url"
|
||||
label = "Base URL"
|
||||
required = true
|
||||
sources = ["flag", "env", "config"]
|
||||
|
||||
[[config.fields]]
|
||||
name = "api_token"
|
||||
flag = "api-token"
|
||||
env = "API_TOKEN"
|
||||
secret_key_template = "profile/{profile}/api-token"
|
||||
type = "secret"
|
||||
label = "API token"
|
||||
required = true
|
||||
sources = ["flag", "env", "secret"]
|
||||
`)
|
||||
writeModule(t, projectDir)
|
||||
|
||||
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(filepath.Join(projectDir, "mcp.toml")); err != nil {
|
||||
t.Fatalf("Remove runtime manifest: %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", "test", "-mod=mod", "./...")
|
||||
cmd.Dir = projectDir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("go test generated project: %v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSecretStoreIncludesBitwardenCacheOption(t *testing.T) {
|
||||
projectDir := newProject(t, `
|
||||
binary_name = "demo-mcp"
|
||||
|
||||
[secret_store]
|
||||
backend_policy = "bitwarden-cli"
|
||||
`)
|
||||
|
||||
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile generated secretstore: %v", err)
|
||||
}
|
||||
text := string(content)
|
||||
for _, snippet := range []string{
|
||||
"DisableBitwardenCache bool",
|
||||
"DisableBitwardenCache: options.DisableBitwardenCache,",
|
||||
} {
|
||||
if !strings.Contains(text, snippet) {
|
||||
t.Fatalf("generated secretstore.go missing %q:\n%s", snippet, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newProject(t *testing.T, manifest string) string {
|
||||
t.Helper()
|
||||
|
||||
projectDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(manifest), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
return projectDir
|
||||
}
|
||||
|
||||
func defaultGeneratedFiles(packageDir string) []string {
|
||||
return []string{
|
||||
filepath.Join(packageDir, "manifest.go"),
|
||||
filepath.Join(packageDir, "metadata.go"),
|
||||
filepath.Join(packageDir, "secretstore.go"),
|
||||
filepath.Join(packageDir, "update.go"),
|
||||
}
|
||||
}
|
||||
|
||||
func generatedFilesWithConfig(packageDir string) []string {
|
||||
return []string{
|
||||
filepath.Join(packageDir, "config.go"),
|
||||
filepath.Join(packageDir, "manifest.go"),
|
||||
filepath.Join(packageDir, "metadata.go"),
|
||||
filepath.Join(packageDir, "secretstore.go"),
|
||||
filepath.Join(packageDir, "update.go"),
|
||||
}
|
||||
}
|
||||
|
||||
func writeModule(t *testing.T, projectDir string) {
|
||||
t.Helper()
|
||||
|
||||
repoRoot, err := filepath.Abs("..")
|
||||
if err != nil {
|
||||
t.Fatalf("Abs repo root: %v", err)
|
||||
}
|
||||
|
||||
goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tforge.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace forge.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n"
|
||||
if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile go.mod: %v", err)
|
||||
}
|
||||
|
||||
goSum, err := os.ReadFile(filepath.Join(repoRoot, "go.sum"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile go.sum: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(projectDir, "go.sum"), goSum, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile go.sum: %v", err)
|
||||
}
|
||||
|
||||
testFile := `package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
"example.com/generated-demo/mcpgen"
|
||||
fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) {
|
||||
file, source, err := mcpgen.LoadManifest(".")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadManifest returned error: %v", err)
|
||||
}
|
||||
if source != fwmanifest.EmbeddedSource {
|
||||
t.Fatalf("source = %q, want %q", source, fwmanifest.EmbeddedSource)
|
||||
}
|
||||
if file.BinaryName != "embedded-demo" {
|
||||
t.Fatalf("binary name = %q", file.BinaryName)
|
||||
}
|
||||
|
||||
info, source, err := mcpgen.BootstrapInfo(".")
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInfo returned error: %v", err)
|
||||
}
|
||||
if source != fwmanifest.EmbeddedSource {
|
||||
t.Fatalf("bootstrap source = %q, want %q", source, fwmanifest.EmbeddedSource)
|
||||
}
|
||||
if info.Description != "Embedded Demo" {
|
||||
t.Fatalf("description = %q", info.Description)
|
||||
}
|
||||
|
||||
updateOptions, err := mcpgen.UpdateOptions("1.2.3", io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateOptions returned error: %v", err)
|
||||
}
|
||||
if updateOptions.CurrentVersion != "1.2.3" {
|
||||
t.Fatalf("current version = %q", updateOptions.CurrentVersion)
|
||||
}
|
||||
if updateOptions.BinaryName != "embedded-demo" {
|
||||
t.Fatalf("update binary name = %q", updateOptions.BinaryName)
|
||||
}
|
||||
if updateOptions.ReleaseSource.Repository != "org/embedded-demo" {
|
||||
t.Fatalf("release repository = %q", updateOptions.ReleaseSource.Repository)
|
||||
}
|
||||
|
||||
store, err := mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
return "secret-from-env", true
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSecretStore returned error: %v", err)
|
||||
}
|
||||
value, err := store.GetSecret("profile/default/api-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if value != "secret-from-env" {
|
||||
t.Fatalf("secret value = %q", value)
|
||||
}
|
||||
if fwsecretstore.EffectiveBackendPolicy(store) != fwsecretstore.BackendEnvOnly {
|
||||
t.Fatalf("effective backend = %q", fwsecretstore.EffectiveBackendPolicy(store))
|
||||
}
|
||||
|
||||
flags := mcpgen.AddConfigFlags(flag.NewFlagSet("test", flag.ContinueOnError))
|
||||
if len(mcpgen.ConfigFlagValues(flags)) != 0 {
|
||||
t.Fatalf("empty flags should not return values")
|
||||
}
|
||||
|
||||
specs := mcpgen.ResolveFieldSpecs("default")
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("field specs = %d, want 2", len(specs))
|
||||
}
|
||||
if specs[1].SecretKey != "profile/default/api-token" {
|
||||
t.Fatalf("secret key = %q", specs[1].SecretKey)
|
||||
}
|
||||
|
||||
setupFields := mcpgen.SetupFields(map[string]string{"api_token": "stored"})
|
||||
if len(setupFields) != 2 {
|
||||
t.Fatalf("setup fields = %d, want 2", len(setupFields))
|
||||
}
|
||||
if setupFields[0].Type != fwcli.SetupFieldURL {
|
||||
t.Fatalf("first setup field type = %q", setupFields[0].Type)
|
||||
}
|
||||
if setupFields[1].ExistingSecret != "stored" {
|
||||
t.Fatalf("existing secret = %q", setupFields[1].ExistingSecret)
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile main_test.go: %v", err)
|
||||
}
|
||||
}
|
||||
2
go.mod
2
go.mod
|
|
@ -1,4 +1,4 @@
|
|||
module gitea.lclr.dev/AI/mcp-framework
|
||||
module forge.lclr.dev/AI/mcp-framework
|
||||
|
||||
go 1.25.0
|
||||
|
||||
|
|
|
|||
|
|
@ -9,21 +9,91 @@ import (
|
|||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/update"
|
||||
"forge.lclr.dev/AI/mcp-framework/update"
|
||||
)
|
||||
|
||||
const DefaultFile = "mcp.toml"
|
||||
const EmbeddedSource = "embedded:mcp.toml"
|
||||
|
||||
type File struct {
|
||||
Update Update `toml:"update"`
|
||||
BinaryName string `toml:"binary_name"`
|
||||
DocsURL string `toml:"docs_url"`
|
||||
Update Update `toml:"update"`
|
||||
Environment Environment `toml:"environment"`
|
||||
SecretStore SecretStore `toml:"secret_store"`
|
||||
Profiles Profiles `toml:"profiles"`
|
||||
Bootstrap Bootstrap `toml:"bootstrap"`
|
||||
Config Config `toml:"config"`
|
||||
}
|
||||
|
||||
type Update struct {
|
||||
SourceName string `toml:"source_name"`
|
||||
BaseURL string `toml:"base_url"`
|
||||
LatestReleaseURL string `toml:"latest_release_url"`
|
||||
TokenHeader string `toml:"token_header"`
|
||||
TokenEnvNames []string `toml:"token_env_names"`
|
||||
SourceName string `toml:"source_name"`
|
||||
Driver string `toml:"driver"`
|
||||
Repository string `toml:"repository"`
|
||||
BaseURL string `toml:"base_url"`
|
||||
LatestReleaseURL string `toml:"latest_release_url"`
|
||||
AssetNameTemplate string `toml:"asset_name_template"`
|
||||
ChecksumAssetName string `toml:"checksum_asset_name"`
|
||||
ChecksumRequired bool `toml:"checksum_required"`
|
||||
SignatureAssetName string `toml:"signature_asset_name"`
|
||||
SignatureRequired bool `toml:"signature_required"`
|
||||
SignaturePublicKey string `toml:"signature_public_key"`
|
||||
SignaturePublicKeyEnvNames []string `toml:"signature_public_key_env_names"`
|
||||
TokenHeader string `toml:"token_header"`
|
||||
TokenPrefix string `toml:"token_prefix"`
|
||||
TokenEnvNames []string `toml:"token_env_names"`
|
||||
}
|
||||
|
||||
type Environment struct {
|
||||
Known []string `toml:"known"`
|
||||
}
|
||||
|
||||
type SecretStore struct {
|
||||
BackendPolicy string `toml:"backend_policy"`
|
||||
BitwardenCache *bool `toml:"bitwarden_cache"`
|
||||
}
|
||||
|
||||
type Profiles struct {
|
||||
Default string `toml:"default"`
|
||||
Known []string `toml:"known"`
|
||||
}
|
||||
|
||||
type Bootstrap struct {
|
||||
Description string `toml:"description"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Fields []ConfigField `toml:"fields"`
|
||||
}
|
||||
|
||||
type ConfigField struct {
|
||||
Name string `toml:"name"`
|
||||
Flag string `toml:"flag"`
|
||||
Env string `toml:"env"`
|
||||
ConfigKey string `toml:"config_key"`
|
||||
SecretKeyTemplate string `toml:"secret_key_template"`
|
||||
Type string `toml:"type"`
|
||||
Label string `toml:"label"`
|
||||
Default string `toml:"default"`
|
||||
Required bool `toml:"required"`
|
||||
Sources []string `toml:"sources"`
|
||||
}
|
||||
|
||||
type BootstrapMetadata struct {
|
||||
BinaryName string
|
||||
Description string
|
||||
DocsURL string
|
||||
DefaultProfile string
|
||||
Profiles []string
|
||||
}
|
||||
|
||||
type ScaffoldMetadata struct {
|
||||
BinaryName string
|
||||
DocsURL string
|
||||
KnownEnvironmentVariables []string
|
||||
SecretStorePolicy string
|
||||
DefaultProfile string
|
||||
Profiles []string
|
||||
}
|
||||
|
||||
func Find(startDir string) (string, error) {
|
||||
|
|
@ -68,9 +138,39 @@ func Load(path string) (File, error) {
|
|||
return File{}, fmt.Errorf("read manifest %s: %w", path, err)
|
||||
}
|
||||
|
||||
return parse(data, path)
|
||||
}
|
||||
|
||||
func LoadEmbedded(content string) (File, string, error) {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if trimmed == "" {
|
||||
return File{}, "", os.ErrNotExist
|
||||
}
|
||||
|
||||
file, err := parse([]byte(trimmed), EmbeddedSource)
|
||||
if err != nil {
|
||||
return File{}, "", err
|
||||
}
|
||||
|
||||
return file, EmbeddedSource, nil
|
||||
}
|
||||
|
||||
func LoadDefaultOrEmbedded(startDir, embeddedContent string) (File, string, error) {
|
||||
file, path, err := LoadDefault(startDir)
|
||||
if err == nil {
|
||||
return file, path, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return File{}, "", err
|
||||
}
|
||||
|
||||
return LoadEmbedded(embeddedContent)
|
||||
}
|
||||
|
||||
func parse(data []byte, source string) (File, error) {
|
||||
var file File
|
||||
if err := toml.Unmarshal(data, &file); err != nil {
|
||||
return File{}, fmt.Errorf("parse manifest %s: %w", path, err)
|
||||
return File{}, fmt.Errorf("parse manifest %s: %w", source, err)
|
||||
}
|
||||
|
||||
file.normalize()
|
||||
|
|
@ -92,32 +192,120 @@ func LoadDefault(startDir string) (File, string, error) {
|
|||
}
|
||||
|
||||
func (f *File) normalize() {
|
||||
f.BinaryName = strings.TrimSpace(f.BinaryName)
|
||||
f.DocsURL = strings.TrimSpace(f.DocsURL)
|
||||
f.Update.normalize()
|
||||
f.Environment.normalize()
|
||||
f.SecretStore.normalize()
|
||||
f.Profiles.normalize()
|
||||
f.Bootstrap.normalize()
|
||||
f.Config.normalize()
|
||||
}
|
||||
|
||||
func (u *Update) normalize() {
|
||||
u.SourceName = strings.TrimSpace(u.SourceName)
|
||||
u.Driver = strings.ToLower(strings.TrimSpace(u.Driver))
|
||||
u.Repository = strings.Trim(strings.TrimSpace(u.Repository), "/")
|
||||
u.BaseURL = strings.TrimRight(strings.TrimSpace(u.BaseURL), "/")
|
||||
u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL)
|
||||
u.AssetNameTemplate = strings.TrimSpace(u.AssetNameTemplate)
|
||||
u.ChecksumAssetName = strings.TrimSpace(u.ChecksumAssetName)
|
||||
u.SignatureAssetName = strings.TrimSpace(u.SignatureAssetName)
|
||||
u.SignaturePublicKey = strings.TrimSpace(u.SignaturePublicKey)
|
||||
u.SignaturePublicKeyEnvNames = normalizeStringList(u.SignaturePublicKeyEnvNames)
|
||||
u.TokenHeader = strings.TrimSpace(u.TokenHeader)
|
||||
u.TokenPrefix = strings.TrimSpace(u.TokenPrefix)
|
||||
u.TokenEnvNames = normalizeStringList(u.TokenEnvNames)
|
||||
}
|
||||
|
||||
envNames := u.TokenEnvNames[:0]
|
||||
for _, envName := range u.TokenEnvNames {
|
||||
if trimmed := strings.TrimSpace(envName); trimmed != "" {
|
||||
envNames = append(envNames, trimmed)
|
||||
}
|
||||
func (e *Environment) normalize() {
|
||||
e.Known = normalizeStringList(e.Known)
|
||||
}
|
||||
|
||||
func (s *SecretStore) normalize() {
|
||||
s.BackendPolicy = strings.TrimSpace(s.BackendPolicy)
|
||||
}
|
||||
|
||||
func (p *Profiles) normalize() {
|
||||
p.Default = strings.TrimSpace(p.Default)
|
||||
p.Known = normalizeStringList(p.Known)
|
||||
}
|
||||
|
||||
func (b *Bootstrap) normalize() {
|
||||
b.Description = strings.TrimSpace(b.Description)
|
||||
}
|
||||
|
||||
func (c *Config) normalize() {
|
||||
for i := range c.Fields {
|
||||
c.Fields[i].normalize()
|
||||
}
|
||||
u.TokenEnvNames = envNames
|
||||
}
|
||||
|
||||
func (f *ConfigField) normalize() {
|
||||
f.Name = strings.TrimSpace(f.Name)
|
||||
f.Flag = strings.TrimSpace(f.Flag)
|
||||
f.Env = strings.TrimSpace(f.Env)
|
||||
f.ConfigKey = strings.TrimSpace(f.ConfigKey)
|
||||
f.SecretKeyTemplate = strings.TrimSpace(f.SecretKeyTemplate)
|
||||
f.Type = strings.ToLower(strings.TrimSpace(f.Type))
|
||||
f.Label = strings.TrimSpace(f.Label)
|
||||
f.Default = strings.TrimSpace(f.Default)
|
||||
f.Sources = normalizeStringList(f.Sources)
|
||||
}
|
||||
|
||||
func (u Update) ReleaseSource() update.ReleaseSource {
|
||||
u.normalize()
|
||||
|
||||
return update.ReleaseSource{
|
||||
Name: u.SourceName,
|
||||
BaseURL: u.BaseURL,
|
||||
LatestReleaseURL: u.LatestReleaseURL,
|
||||
TokenHeader: u.TokenHeader,
|
||||
TokenEnvNames: append([]string(nil), u.TokenEnvNames...),
|
||||
Name: u.SourceName,
|
||||
Driver: u.Driver,
|
||||
Repository: u.Repository,
|
||||
BaseURL: u.BaseURL,
|
||||
LatestReleaseURL: u.LatestReleaseURL,
|
||||
AssetNameTemplate: u.AssetNameTemplate,
|
||||
ChecksumAssetName: u.ChecksumAssetName,
|
||||
ChecksumRequired: u.ChecksumRequired,
|
||||
SignatureAssetName: u.SignatureAssetName,
|
||||
SignatureRequired: u.SignatureRequired,
|
||||
SignaturePublicKey: u.SignaturePublicKey,
|
||||
SignaturePublicKeyEnvNames: append([]string(nil), u.SignaturePublicKeyEnvNames...),
|
||||
TokenHeader: u.TokenHeader,
|
||||
TokenPrefix: u.TokenPrefix,
|
||||
TokenEnvNames: append([]string(nil), u.TokenEnvNames...),
|
||||
}
|
||||
}
|
||||
|
||||
func (f File) BootstrapInfo() BootstrapMetadata {
|
||||
f.normalize()
|
||||
|
||||
return BootstrapMetadata{
|
||||
BinaryName: f.BinaryName,
|
||||
Description: f.Bootstrap.Description,
|
||||
DocsURL: f.DocsURL,
|
||||
DefaultProfile: f.Profiles.Default,
|
||||
Profiles: append([]string(nil), f.Profiles.Known...),
|
||||
}
|
||||
}
|
||||
|
||||
func (f File) ScaffoldInfo() ScaffoldMetadata {
|
||||
f.normalize()
|
||||
|
||||
return ScaffoldMetadata{
|
||||
BinaryName: f.BinaryName,
|
||||
DocsURL: f.DocsURL,
|
||||
KnownEnvironmentVariables: append([]string(nil), f.Environment.Known...),
|
||||
SecretStorePolicy: f.SecretStore.BackendPolicy,
|
||||
DefaultProfile: f.Profiles.Default,
|
||||
Profiles: append([]string(nil), f.Profiles.Known...),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStringList(values []string) []string {
|
||||
normalized := values[:0]
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
normalized = append(normalized, trimmed)
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -45,9 +46,19 @@ func TestLoadParsesUpdateConfig(t *testing.T) {
|
|||
const content = `
|
||||
[update]
|
||||
source_name = " Gitea releases "
|
||||
driver = " Gitea "
|
||||
repository = " org/repo "
|
||||
base_url = "https://gitea.example.com/"
|
||||
latest_release_url = "https://gitea.example.com/api/releases/latest"
|
||||
asset_name_template = "{binary}_{os}_{arch}{ext}"
|
||||
checksum_asset_name = "{asset}.sha256"
|
||||
checksum_required = true
|
||||
signature_asset_name = "{asset}.sig"
|
||||
signature_required = true
|
||||
signature_public_key = " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef "
|
||||
signature_public_key_env_names = [" MCP_PUBKEY ", "", "MCP_RELEASE_PUBKEY"]
|
||||
token_header = " Authorization "
|
||||
token_prefix = " token "
|
||||
token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"]
|
||||
`
|
||||
|
||||
|
|
@ -64,15 +75,45 @@ token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"]
|
|||
if source.Name != "Gitea releases" {
|
||||
t.Fatalf("source name = %q", source.Name)
|
||||
}
|
||||
if source.Driver != "gitea" {
|
||||
t.Fatalf("driver = %q", source.Driver)
|
||||
}
|
||||
if source.Repository != "org/repo" {
|
||||
t.Fatalf("repository = %q", source.Repository)
|
||||
}
|
||||
if source.BaseURL != "https://gitea.example.com" {
|
||||
t.Fatalf("base URL = %q", source.BaseURL)
|
||||
}
|
||||
if source.LatestReleaseURL != "https://gitea.example.com/api/releases/latest" {
|
||||
t.Fatalf("latest release URL = %q", source.LatestReleaseURL)
|
||||
}
|
||||
if source.AssetNameTemplate != "{binary}_{os}_{arch}{ext}" {
|
||||
t.Fatalf("asset name template = %q", source.AssetNameTemplate)
|
||||
}
|
||||
if source.ChecksumAssetName != "{asset}.sha256" {
|
||||
t.Fatalf("checksum asset name = %q", source.ChecksumAssetName)
|
||||
}
|
||||
if !source.ChecksumRequired {
|
||||
t.Fatal("checksum required should be true")
|
||||
}
|
||||
if source.SignatureAssetName != "{asset}.sig" {
|
||||
t.Fatalf("signature asset name = %q", source.SignatureAssetName)
|
||||
}
|
||||
if !source.SignatureRequired {
|
||||
t.Fatal("signature required should be true")
|
||||
}
|
||||
if source.SignaturePublicKey == "" {
|
||||
t.Fatal("signature public key should be set")
|
||||
}
|
||||
if len(source.SignaturePublicKeyEnvNames) != 2 {
|
||||
t.Fatalf("signature env names = %v", source.SignaturePublicKeyEnvNames)
|
||||
}
|
||||
if source.TokenHeader != "Authorization" {
|
||||
t.Fatalf("token header = %q", source.TokenHeader)
|
||||
}
|
||||
if source.TokenPrefix != "token" {
|
||||
t.Fatalf("token prefix = %q", source.TokenPrefix)
|
||||
}
|
||||
if len(source.TokenEnvNames) != 2 {
|
||||
t.Fatalf("token env names = %v", source.TokenEnvNames)
|
||||
}
|
||||
|
|
@ -114,3 +155,264 @@ func TestLoadReturnsParseError(t *testing.T) {
|
|||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesExtendedManifestMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, DefaultFile)
|
||||
|
||||
const content = `
|
||||
binary_name = " my-mcp "
|
||||
docs_url = " https://docs.example.com/mcp "
|
||||
|
||||
[update]
|
||||
latest_release_url = "https://example.com/latest"
|
||||
|
||||
[environment]
|
||||
known = [" MCP_PROFILE ", "", "MCP_TOKEN"]
|
||||
|
||||
[secret_store]
|
||||
backend_policy = " auto "
|
||||
|
||||
[profiles]
|
||||
default = " prod "
|
||||
known = [" default ", "", "prod"]
|
||||
|
||||
[bootstrap]
|
||||
description = " Client MCP interne "
|
||||
`
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
file, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if file.BinaryName != "my-mcp" {
|
||||
t.Fatalf("binary name = %q", file.BinaryName)
|
||||
}
|
||||
if file.DocsURL != "https://docs.example.com/mcp" {
|
||||
t.Fatalf("docs URL = %q", file.DocsURL)
|
||||
}
|
||||
if !slices.Equal(file.Environment.Known, []string{"MCP_PROFILE", "MCP_TOKEN"}) {
|
||||
t.Fatalf("environment known = %v", file.Environment.Known)
|
||||
}
|
||||
if file.SecretStore.BackendPolicy != "auto" {
|
||||
t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy)
|
||||
}
|
||||
if file.Profiles.Default != "prod" {
|
||||
t.Fatalf("default profile = %q", file.Profiles.Default)
|
||||
}
|
||||
if !slices.Equal(file.Profiles.Known, []string{"default", "prod"}) {
|
||||
t.Fatalf("profiles known = %v", file.Profiles.Known)
|
||||
}
|
||||
if file.Bootstrap.Description != "Client MCP interne" {
|
||||
t.Fatalf("bootstrap description = %q", file.Bootstrap.Description)
|
||||
}
|
||||
|
||||
bootstrap := file.BootstrapInfo()
|
||||
if bootstrap.BinaryName != "my-mcp" {
|
||||
t.Fatalf("bootstrap binary name = %q", bootstrap.BinaryName)
|
||||
}
|
||||
if bootstrap.DocsURL != "https://docs.example.com/mcp" {
|
||||
t.Fatalf("bootstrap docs URL = %q", bootstrap.DocsURL)
|
||||
}
|
||||
if bootstrap.DefaultProfile != "prod" {
|
||||
t.Fatalf("bootstrap default profile = %q", bootstrap.DefaultProfile)
|
||||
}
|
||||
if !slices.Equal(bootstrap.Profiles, []string{"default", "prod"}) {
|
||||
t.Fatalf("bootstrap profiles = %v", bootstrap.Profiles)
|
||||
}
|
||||
|
||||
scaffold := file.ScaffoldInfo()
|
||||
if scaffold.SecretStorePolicy != "auto" {
|
||||
t.Fatalf("scaffold secret store policy = %q", scaffold.SecretStorePolicy)
|
||||
}
|
||||
if !slices.Equal(scaffold.KnownEnvironmentVariables, []string{"MCP_PROFILE", "MCP_TOKEN"}) {
|
||||
t.Fatalf("scaffold known environment variables = %v", scaffold.KnownEnvironmentVariables)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesSecretStoreBitwardenCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, DefaultFile)
|
||||
|
||||
const content = `
|
||||
[secret_store]
|
||||
backend_policy = "bitwarden-cli"
|
||||
bitwarden_cache = false
|
||||
`
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
file, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if file.SecretStore.BitwardenCache == nil {
|
||||
t.Fatal("bitwarden cache option is nil, want explicit false pointer")
|
||||
}
|
||||
if *file.SecretStore.BitwardenCache {
|
||||
t.Fatal("bitwarden cache option = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLeavesOmittedBitwardenCacheUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, DefaultFile)
|
||||
|
||||
const content = `
|
||||
[secret_store]
|
||||
backend_policy = "bitwarden-cli"
|
||||
`
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
file, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if file.SecretStore.BitwardenCache != nil {
|
||||
t.Fatalf("bitwarden cache option = %v, want nil when omitted", *file.SecretStore.BitwardenCache)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesConfigFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, DefaultFile)
|
||||
|
||||
const content = `
|
||||
[[config.fields]]
|
||||
name = " base_url "
|
||||
flag = "base-url"
|
||||
env = "BASE_URL"
|
||||
config_key = "base_url"
|
||||
type = " url "
|
||||
label = " Graylog URL "
|
||||
required = true
|
||||
sources = [" flag ", "env", "config"]
|
||||
|
||||
[[config.fields]]
|
||||
name = "api_token"
|
||||
flag = "api-token"
|
||||
env = "API_TOKEN"
|
||||
secret_key_template = "profile/{profile}/api-token"
|
||||
type = "secret"
|
||||
required = true
|
||||
sources = ["flag", "env", "secret"]
|
||||
`
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
file, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(file.Config.Fields) != 2 {
|
||||
t.Fatalf("config fields = %d, want 2", len(file.Config.Fields))
|
||||
}
|
||||
|
||||
baseURL := file.Config.Fields[0]
|
||||
if baseURL.Name != "base_url" {
|
||||
t.Fatalf("base URL name = %q", baseURL.Name)
|
||||
}
|
||||
if baseURL.Flag != "base-url" {
|
||||
t.Fatalf("base URL flag = %q", baseURL.Flag)
|
||||
}
|
||||
if baseURL.Env != "BASE_URL" {
|
||||
t.Fatalf("base URL env = %q", baseURL.Env)
|
||||
}
|
||||
if baseURL.ConfigKey != "base_url" {
|
||||
t.Fatalf("base URL config key = %q", baseURL.ConfigKey)
|
||||
}
|
||||
if baseURL.Type != "url" {
|
||||
t.Fatalf("base URL type = %q", baseURL.Type)
|
||||
}
|
||||
if baseURL.Label != "Graylog URL" {
|
||||
t.Fatalf("base URL label = %q", baseURL.Label)
|
||||
}
|
||||
if !baseURL.Required {
|
||||
t.Fatal("base URL should be required")
|
||||
}
|
||||
if !slices.Equal(baseURL.Sources, []string{"flag", "env", "config"}) {
|
||||
t.Fatalf("base URL sources = %v", baseURL.Sources)
|
||||
}
|
||||
|
||||
token := file.Config.Fields[1]
|
||||
if token.SecretKeyTemplate != "profile/{profile}/api-token" {
|
||||
t.Fatalf("token secret key template = %q", token.SecretKeyTemplate)
|
||||
}
|
||||
if !slices.Equal(token.Sources, []string{"flag", "env", "secret"}) {
|
||||
t.Fatalf("token sources = %v", token.Sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEmbeddedParsesContent(t *testing.T) {
|
||||
file, source, err := LoadEmbedded(`
|
||||
[update]
|
||||
latest_release_url = "https://example.com/latest"
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEmbedded returned error: %v", err)
|
||||
}
|
||||
if source != EmbeddedSource {
|
||||
t.Fatalf("source = %q, want %q", source, EmbeddedSource)
|
||||
}
|
||||
if file.Update.LatestReleaseURL != "https://example.com/latest" {
|
||||
t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEmbeddedReturnsNotExistWhenEmpty(t *testing.T) {
|
||||
_, _, err := LoadEmbedded(" ")
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("error = %v, want os.ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultOrEmbeddedPrefersManifestFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, DefaultFile)
|
||||
if err := os.WriteFile(path, []byte("[update]\nlatest_release_url = \"https://example.com/from-file\"\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
file, source, err := LoadDefaultOrEmbedded(root, `
|
||||
[update]
|
||||
latest_release_url = "https://example.com/from-embedded"
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDefaultOrEmbedded returned error: %v", err)
|
||||
}
|
||||
if source != path {
|
||||
t.Fatalf("source = %q, want %q", source, path)
|
||||
}
|
||||
if file.Update.LatestReleaseURL != "https://example.com/from-file" {
|
||||
t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultOrEmbeddedUsesEmbeddedWhenFileMissing(t *testing.T) {
|
||||
file, source, err := LoadDefaultOrEmbedded(t.TempDir(), `
|
||||
[update]
|
||||
latest_release_url = "https://example.com/from-embedded"
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDefaultOrEmbedded returned error: %v", err)
|
||||
}
|
||||
if source != EmbeddedSource {
|
||||
t.Fatalf("source = %q, want %q", source, EmbeddedSource)
|
||||
}
|
||||
if file.Update.LatestReleaseURL != "https://example.com/from-embedded" {
|
||||
t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2136
scaffold/scaffold.go
Normal file
2136
scaffold/scaffold.go
Normal file
File diff suppressed because it is too large
Load diff
243
scaffold/scaffold_test.go
Normal file
243
scaffold/scaffold_test.go
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
package scaffold
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "my-mcp")
|
||||
|
||||
result, err := Generate(Options{
|
||||
TargetDir: target,
|
||||
ModulePath: "example.com/acme/my-mcp",
|
||||
BinaryName: "my-mcp",
|
||||
Description: "Client MCP interne",
|
||||
DefaultProfile: "prod",
|
||||
Profiles: []string{"dev", "prod"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
if result.Root != target {
|
||||
t.Fatalf("result root = %q, want %q", result.Root, target)
|
||||
}
|
||||
|
||||
wantFiles := []string{
|
||||
".gitignore",
|
||||
"README.md",
|
||||
"cmd/my-mcp/main.go",
|
||||
"go.mod",
|
||||
"install.sh",
|
||||
"internal/app/app.go",
|
||||
"mcp.toml",
|
||||
}
|
||||
if !slices.Equal(result.Files, wantFiles) {
|
||||
t.Fatalf("result files = %v, want %v", result.Files, wantFiles)
|
||||
}
|
||||
|
||||
for _, path := range wantFiles {
|
||||
if _, err := os.Stat(filepath.Join(target, filepath.FromSlash(path))); err != nil {
|
||||
t.Fatalf("generated file %q missing: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
mainGo, err := os.ReadFile(filepath.Join(target, "cmd", "my-mcp", "main.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile main.go: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(mainGo), "\"example.com/acme/my-mcp/internal/app\"") {
|
||||
t.Fatalf("main.go does not import internal app package")
|
||||
}
|
||||
if _, err := parser.ParseFile(token.NewFileSet(), "main.go", mainGo, parser.AllErrors); err != nil {
|
||||
t.Fatalf("generated main.go is invalid Go: %v", err)
|
||||
}
|
||||
|
||||
appGo, err := os.ReadFile(filepath.Join(target, "internal", "app", "app.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile app.go: %v", err)
|
||||
}
|
||||
for _, snippet := range []string{
|
||||
"config.NewStore[Profile]",
|
||||
"secretstore.Open(secretstore.Options",
|
||||
"secretstore.EnsureBitwardenSessionEnv",
|
||||
"func (r Runtime) ensureBitwardenSession() error {",
|
||||
"\\033[31m",
|
||||
"secretstore.LoginBitwarden",
|
||||
"update.Run",
|
||||
"manifest.LoadDefaultOrEmbedded",
|
||||
"bootstrap.Run",
|
||||
"os.Executable()",
|
||||
"errors.Is(err, os.ErrNotExist)",
|
||||
`var embeddedManifest = `,
|
||||
"ManifestSource",
|
||||
"ManifestCheck: r.manifestDoctorCheck()",
|
||||
"SecretBackendPolicy: r.activeBackendPolicy()",
|
||||
"Login: r.runLogin,",
|
||||
"func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error {",
|
||||
"secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)",
|
||||
"func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {",
|
||||
"cli.WriteSetupSecretVerified",
|
||||
} {
|
||||
if !strings.Contains(string(appGo), snippet) {
|
||||
t.Fatalf("app.go missing snippet %q", snippet)
|
||||
}
|
||||
}
|
||||
if _, err := parser.ParseFile(token.NewFileSet(), "app.go", appGo, parser.AllErrors); err != nil {
|
||||
t.Fatalf("generated app.go is invalid Go: %v", err)
|
||||
}
|
||||
|
||||
manifestContent, err := os.ReadFile(filepath.Join(target, "mcp.toml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile mcp.toml: %v", err)
|
||||
}
|
||||
for _, snippet := range []string{
|
||||
"binary_name = \"my-mcp\"",
|
||||
"[update]",
|
||||
"checksum_required = true",
|
||||
"signature_asset_name = \"{asset}.sig\"",
|
||||
"signature_required = false",
|
||||
"[secret_store]",
|
||||
"[environment]",
|
||||
"[profiles]",
|
||||
"backend_policy = \"auto\"",
|
||||
} {
|
||||
if !strings.Contains(string(manifestContent), snippet) {
|
||||
t.Fatalf("mcp.toml missing snippet %q", snippet)
|
||||
}
|
||||
}
|
||||
|
||||
readme, err := os.ReadFile(filepath.Join(target, "README.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile README.md: %v", err)
|
||||
}
|
||||
for _, snippet := range []string{
|
||||
"Arborescence générée",
|
||||
"go run ./cmd/my-mcp setup",
|
||||
"curl -fsSL https://<forge>/<org>/<repo>/raw/branch/main/install.sh | bash",
|
||||
"internal/app/app.go",
|
||||
} {
|
||||
if !strings.Contains(string(readme), snippet) {
|
||||
t.Fatalf("README missing snippet %q", snippet)
|
||||
}
|
||||
}
|
||||
|
||||
installScriptPath := filepath.Join(target, "install.sh")
|
||||
installScript, err := os.ReadFile(installScriptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile install.sh: %v", err)
|
||||
}
|
||||
for _, snippet := range []string{
|
||||
"#!/usr/bin/env bash",
|
||||
`MODULE_PATH="example.com/acme/my-mcp"`,
|
||||
`DEFAULT_RELEASE_REPOSITORY="org/my-mcp"`,
|
||||
`load_release_config_from_manifest`,
|
||||
`resolve_latest_release_url()`,
|
||||
`curl_download "$release_url" "$release_json" "json"`,
|
||||
`asset_name="$(resolve_asset_name "$goos" "$goarch")"`,
|
||||
`Reinstaller depuis la derniere release ? (y/N)`,
|
||||
"MCP Install Wizard",
|
||||
`menu_select() {`,
|
||||
`Utilise ↑/↓ puis Entrée.`,
|
||||
`Configurer le MCP maintenant ?`,
|
||||
`claude mcp add \`,
|
||||
`--transport stdio \`,
|
||||
`--scope "$claude_scope" \`,
|
||||
`--env "${PROFILE_ENV}=${PROFILE_VALUE}" \`,
|
||||
`codex mcp add \`,
|
||||
`Dossier projet cible pour .codex/config.toml`,
|
||||
`[mcp_servers.%s]`,
|
||||
`"${PROFILE_ENV}=${PROFILE_VALUE}"`,
|
||||
} {
|
||||
if !strings.Contains(string(installScript), snippet) {
|
||||
t.Fatalf("install.sh missing snippet %q", snippet)
|
||||
}
|
||||
}
|
||||
|
||||
info, err := os.Stat(installScriptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat install.sh: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o755 {
|
||||
t.Fatalf("install.sh mode = %o, want 755", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateUsesDefaultsFromTargetDirectory(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "super-agent-mcp")
|
||||
|
||||
_, err := Generate(Options{TargetDir: target})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
goModContent, err := os.ReadFile(filepath.Join(target, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile go.mod: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(goModContent), "module example.com/super-agent-mcp") {
|
||||
t.Fatalf("go.mod should contain default module path")
|
||||
}
|
||||
|
||||
manifestContent, err := os.ReadFile(filepath.Join(target, "mcp.toml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile mcp.toml: %v", err)
|
||||
}
|
||||
for _, snippet := range []string{
|
||||
"binary_name = \"super-agent-mcp\"",
|
||||
"SUPER_AGENT_MCP_PROFILE",
|
||||
"SUPER_AGENT_MCP_API_TOKEN",
|
||||
} {
|
||||
if !strings.Contains(string(manifestContent), snippet) {
|
||||
t.Fatalf("mcp.toml missing snippet %q", snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateFailsWhenFileAlreadyExistsWithoutOverwrite(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
readmePath := filepath.Join(target, "README.md")
|
||||
if err := os.WriteFile(readmePath, []byte("pre-existing"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile README.md: %v", err)
|
||||
}
|
||||
|
||||
_, err := Generate(Options{TargetDir: target})
|
||||
if !errors.Is(err, ErrFileExists) {
|
||||
t.Fatalf("Generate error = %v, want ErrFileExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateOverwritesExistingFilesWhenRequested(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
readmePath := filepath.Join(target, "README.md")
|
||||
if err := os.WriteFile(readmePath, []byte("pre-existing"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile README.md: %v", err)
|
||||
}
|
||||
|
||||
_, err := Generate(Options{TargetDir: target, Overwrite: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
readmeContent, err := os.ReadFile(readmePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile README.md: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(readmeContent), "Démarrage rapide") {
|
||||
t.Fatalf("README should be overwritten with scaffold content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRequiresTargetDirectory(t *testing.T) {
|
||||
_, err := Generate(Options{})
|
||||
if !errors.Is(err, ErrTargetDirRequired) {
|
||||
t.Fatalf("Generate error = %v, want ErrTargetDirRequired", err)
|
||||
}
|
||||
}
|
||||
936
secretstore/bitwarden.go
Normal file
936
secretstore/bitwarden.go
Normal file
|
|
@ -0,0 +1,936 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBitwardenCommand = "bw"
|
||||
bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG"
|
||||
bitwardenSessionEnvName = "BW_SESSION"
|
||||
bitwardenSecretFieldName = "mcp-secret"
|
||||
bitwardenServiceFieldName = "mcp-service"
|
||||
bitwardenSecretNameFieldName = "mcp-secret-name"
|
||||
bitwardenLoaderMessage = "Waiting BitWarden..."
|
||||
bitwardenLoaderInterval = 90 * time.Millisecond
|
||||
bitwardenLoaderColorBase = "\033[38;5;39m"
|
||||
bitwardenLoaderColorWave = "\033[38;5;81m"
|
||||
bitwardenLoaderColorFocus = "\033[38;5;117m"
|
||||
bitwardenLoaderResetColor = "\033[0m"
|
||||
bitwardenLoaderClearLine = "\r\033[2K"
|
||||
)
|
||||
|
||||
type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error)
|
||||
type bitwardenInteractiveRunner func(
|
||||
command string,
|
||||
stdin io.Reader,
|
||||
stdout, stderr io.Writer,
|
||||
args ...string,
|
||||
) ([]byte, error)
|
||||
|
||||
var runBitwardenCLI bitwardenRunner = executeBitwardenCLI
|
||||
var runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive
|
||||
var startBitwardenLoaderFunc = startBitwardenLoader
|
||||
var bitwardenLoaderActive atomic.Bool
|
||||
var bitwardenDebugOutput io.Writer = os.Stderr
|
||||
|
||||
type bitwardenStore struct {
|
||||
command string
|
||||
serviceName string
|
||||
debug bool
|
||||
lookupEnv func(string) (string, bool)
|
||||
shell string
|
||||
cache *bitwardenCache
|
||||
}
|
||||
|
||||
type bitwardenListItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type bitwardenStatusOutput struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func newBitwardenStore(options Options, policy BackendPolicy, serviceName string) (Store, error) {
|
||||
command := strings.TrimSpace(options.BitwardenCommand)
|
||||
if command == "" {
|
||||
command = defaultBitwardenCommand
|
||||
}
|
||||
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
||||
|
||||
if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{
|
||||
ServiceName: serviceName,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"secret backend policy %q cannot load persisted bitwarden session for service %q: %w",
|
||||
policy,
|
||||
serviceName,
|
||||
errors.Join(ErrBackendUnavailable, err),
|
||||
)
|
||||
}
|
||||
|
||||
session, _ := os.LookupEnv(bitwardenSessionEnvName)
|
||||
cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv()
|
||||
store := &bitwardenStore{
|
||||
command: command,
|
||||
serviceName: serviceName,
|
||||
debug: debugEnabled,
|
||||
lookupEnv: options.LookupEnv,
|
||||
shell: options.Shell,
|
||||
cache: newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: serviceName,
|
||||
Session: session,
|
||||
TTL: defaultBitwardenCacheTTL,
|
||||
CacheDir: resolveBitwardenCacheDir(serviceName),
|
||||
Enabled: cacheEnabled,
|
||||
}),
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func verifyBitwardenCLIReady(options Options) error {
|
||||
command := strings.TrimSpace(options.BitwardenCommand)
|
||||
if command == "" {
|
||||
command = defaultBitwardenCommand
|
||||
}
|
||||
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
||||
|
||||
if _, err := runBitwardenCommand(command, debugEnabled, nil, "--version"); err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return fmt.Errorf(
|
||||
"requires bitwarden CLI command %q in PATH: %w",
|
||||
command,
|
||||
errors.Join(ErrBackendUnavailable, err),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"cannot verify bitwarden CLI command %q: %w",
|
||||
command,
|
||||
errors.Join(ErrBackendUnavailable, err),
|
||||
)
|
||||
}
|
||||
|
||||
if err := EnsureBitwardenReady(options); err != nil {
|
||||
return fmt.Errorf(
|
||||
"cannot use bitwarden CLI command %q right now: %w",
|
||||
command,
|
||||
errors.Join(ErrBackendUnavailable, err),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureBitwardenReady(options Options) error {
|
||||
command := strings.TrimSpace(options.BitwardenCommand)
|
||||
if command == "" {
|
||||
command = defaultBitwardenCommand
|
||||
}
|
||||
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
||||
unlockCommand := bitwardenUnlockRemediation(command, options.Shell)
|
||||
|
||||
status, err := readBitwardenStatus(command, debugEnabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch status {
|
||||
case "unauthenticated":
|
||||
return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn)
|
||||
case "locked":
|
||||
return fmt.Errorf(
|
||||
"%w: run `%s` then retry",
|
||||
ErrBWLocked,
|
||||
unlockCommand,
|
||||
)
|
||||
case "unlocked":
|
||||
lookupEnv := options.LookupEnv
|
||||
if lookupEnv == nil {
|
||||
lookupEnv = os.LookupEnv
|
||||
}
|
||||
|
||||
session, ok := lookupEnv(bitwardenSessionEnvName)
|
||||
if !ok || strings.TrimSpace(session) == "" {
|
||||
session, ok = os.LookupEnv(bitwardenSessionEnvName)
|
||||
}
|
||||
if !ok || strings.TrimSpace(session) == "" {
|
||||
return fmt.Errorf(
|
||||
"%w: environment variable %q is missing; run `%s` then retry",
|
||||
ErrBWLocked,
|
||||
bitwardenSessionEnvName,
|
||||
unlockCommand,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"%w: unsupported bitwarden status %q",
|
||||
ErrBWUnavailable,
|
||||
status,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func readBitwardenStatus(command string, debug bool) (string, error) {
|
||||
output, err := runBitwardenCommand(command, debug, nil, "status")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("check bitwarden CLI status: %w", err)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(string(output))
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("%w: bitwarden CLI returned an empty status", ErrBWUnavailable)
|
||||
}
|
||||
|
||||
var status bitwardenStatusOutput
|
||||
if err := json.Unmarshal([]byte(trimmed), &status); err != nil {
|
||||
return "", fmt.Errorf("decode bitwarden CLI status: %w", errors.Join(ErrBWUnavailable, err))
|
||||
}
|
||||
|
||||
return strings.ToLower(strings.TrimSpace(status.Status)), nil
|
||||
}
|
||||
|
||||
func bitwardenUnlockRemediation(command, shellHint string) string {
|
||||
unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command))
|
||||
|
||||
switch detectShellFlavor(shellHint) {
|
||||
case "fish":
|
||||
return fmt.Sprintf("set -x %s (%s)", bitwardenSessionEnvName, unlockCommand)
|
||||
case "powershell":
|
||||
return fmt.Sprintf("$env:%s = (%s)", bitwardenSessionEnvName, unlockCommand)
|
||||
case "cmd":
|
||||
return fmt.Sprintf(
|
||||
"for /f \"usebackq delims=\" %%i in (`%s`) do set %s=%%i",
|
||||
unlockCommand,
|
||||
bitwardenSessionEnvName,
|
||||
)
|
||||
default:
|
||||
return fmt.Sprintf("export %s=\"$(%s)\"", bitwardenSessionEnvName, unlockCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func detectShellFlavor(shellHint string) string {
|
||||
raw := strings.TrimSpace(shellHint)
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(os.Getenv("SHELL"))
|
||||
}
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(os.Getenv("COMSPEC"))
|
||||
}
|
||||
if raw == "" && runtime.GOOS == "windows" {
|
||||
return "powershell"
|
||||
}
|
||||
|
||||
lower := strings.ToLower(strings.TrimSpace(raw))
|
||||
base := strings.ToLower(filepath.Base(lower))
|
||||
|
||||
switch {
|
||||
case strings.Contains(lower, "powershell"),
|
||||
strings.Contains(lower, "pwsh"),
|
||||
base == "powershell",
|
||||
base == "powershell.exe",
|
||||
base == "pwsh",
|
||||
base == "pwsh.exe":
|
||||
return "powershell"
|
||||
case strings.Contains(lower, "fish"), base == "fish":
|
||||
return "fish"
|
||||
case strings.Contains(lower, "cmd.exe"), base == "cmd", base == "cmd.exe":
|
||||
return "cmd"
|
||||
default:
|
||||
return "posix"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) SetSecret(name, label, secret string) error {
|
||||
secretName := s.scopedName(name)
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return fmt.Errorf("prepare bitwarden CLI for saving secret %q: %w", name, err)
|
||||
}
|
||||
|
||||
item, payload, err := s.findItem(secretName, name)
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
template, err := s.itemTemplate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
setBitwardenSecretPayload(template, s.serviceName, name, secretName, label, secret)
|
||||
encoded, err := s.encodePayload(template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.execute(
|
||||
fmt.Sprintf("create bitwarden item for secret %q", name),
|
||||
nil,
|
||||
"create",
|
||||
"item",
|
||||
encoded,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.invalidate(name, secretName)
|
||||
s.cache.store(name, secretName, secret)
|
||||
}
|
||||
return nil
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
|
||||
setBitwardenSecretPayload(payload, s.serviceName, name, secretName, label, secret)
|
||||
encoded, err := s.encodePayload(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.execute(
|
||||
fmt.Sprintf("update bitwarden item for secret %q", name),
|
||||
nil,
|
||||
"edit",
|
||||
"item",
|
||||
item.ID,
|
||||
encoded,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
s.cache.invalidate(name, secretName)
|
||||
s.cache.store(name, secretName, secret)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) GetSecret(name string) (string, error) {
|
||||
secretName := s.scopedName(name)
|
||||
if s.cache != nil {
|
||||
if secret, ok := s.cache.load(name, secretName); ok {
|
||||
return secret, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return "", fmt.Errorf("prepare bitwarden CLI for reading secret %q: %w", name, err)
|
||||
}
|
||||
|
||||
_, payload, err := s.findItem(secretName, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
secret, ok := readBitwardenSecret(payload)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName)
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
s.cache.store(name, secretName, secret)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) DeleteSecret(name string) error {
|
||||
secretName := s.scopedName(name)
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return fmt.Errorf("prepare bitwarden CLI for deleting secret %q: %w", name, err)
|
||||
}
|
||||
|
||||
item, _, err := s.findItem(secretName, name)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
if s.cache != nil {
|
||||
s.cache.invalidate(name, secretName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.execute(
|
||||
fmt.Sprintf("delete bitwarden item for secret %q", name),
|
||||
nil,
|
||||
"delete",
|
||||
"item",
|
||||
item.ID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
s.cache.invalidate(name, secretName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) scopedName(name string) string {
|
||||
return fmt.Sprintf("%s/%s", s.serviceName, name)
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) ensureReady() error {
|
||||
s.refreshSessionEnv()
|
||||
return verifyBitwardenCLIReady(Options{
|
||||
BitwardenCommand: s.command,
|
||||
BitwardenDebug: s.debug,
|
||||
LookupEnv: s.lookupEnv,
|
||||
Shell: s.shell,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) refreshSessionEnv() {
|
||||
session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
|
||||
if err != nil || strings.TrimSpace(session) == "" {
|
||||
return
|
||||
}
|
||||
_ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session))
|
||||
}
|
||||
|
||||
type bitwardenResolvedItem struct {
|
||||
item bitwardenListItem
|
||||
payload map[string]any
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenListItem, map[string]any, error) {
|
||||
output, err := s.execute(
|
||||
fmt.Sprintf("list bitwarden items for secret %q", secretName),
|
||||
nil,
|
||||
"list",
|
||||
"items",
|
||||
"--search",
|
||||
secretName,
|
||||
)
|
||||
if err != nil {
|
||||
return bitwardenListItem{}, nil, err
|
||||
}
|
||||
if strings.TrimSpace(string(output)) == "" {
|
||||
return bitwardenListItem{}, nil, ErrNotFound
|
||||
}
|
||||
|
||||
var items []bitwardenListItem
|
||||
if err := json.Unmarshal(output, &items); err != nil {
|
||||
return bitwardenListItem{}, nil, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err)
|
||||
}
|
||||
|
||||
matches := make([]bitwardenListItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item.Name) != secretName {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(item.ID) == "" {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, item)
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
return bitwardenListItem{}, nil, ErrNotFound
|
||||
}
|
||||
|
||||
markedMatches := make([]bitwardenResolvedItem, 0, len(matches))
|
||||
legacyMatches := make([]bitwardenResolvedItem, 0, len(matches))
|
||||
for _, item := range matches {
|
||||
payload, err := s.itemByID(item.ID)
|
||||
if err != nil {
|
||||
return bitwardenListItem{}, nil, err
|
||||
}
|
||||
|
||||
resolved := bitwardenResolvedItem{
|
||||
item: item,
|
||||
payload: payload,
|
||||
}
|
||||
|
||||
if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) {
|
||||
markedMatches = append(markedMatches, resolved)
|
||||
continue
|
||||
}
|
||||
legacyMatches = append(legacyMatches, resolved)
|
||||
}
|
||||
|
||||
switch len(markedMatches) {
|
||||
case 0:
|
||||
switch len(legacyMatches) {
|
||||
case 0:
|
||||
return bitwardenListItem{}, nil, ErrNotFound
|
||||
case 1:
|
||||
return legacyMatches[0].item, legacyMatches[0].payload, nil
|
||||
default:
|
||||
return bitwardenListItem{}, nil, fmt.Errorf(
|
||||
"multiple legacy bitwarden items match secret %q for service %q",
|
||||
secretName,
|
||||
s.serviceName,
|
||||
)
|
||||
}
|
||||
case 1:
|
||||
return markedMatches[0].item, markedMatches[0].payload, nil
|
||||
default:
|
||||
return bitwardenListItem{}, nil, fmt.Errorf(
|
||||
"multiple bitwarden items share marker for secret %q and service %q",
|
||||
secretName,
|
||||
s.serviceName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) itemTemplate() (map[string]any, error) {
|
||||
output, err := s.execute("load bitwarden item template", nil, "get", "template", "item")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output, &payload); err != nil {
|
||||
return nil, fmt.Errorf("decode bitwarden item template: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) itemByID(id string) (map[string]any, error) {
|
||||
trimmedID := strings.TrimSpace(id)
|
||||
if trimmedID == "" {
|
||||
return nil, errors.New("bitwarden item id must not be empty")
|
||||
}
|
||||
|
||||
output, err := s.execute(
|
||||
fmt.Sprintf("read bitwarden item %q", trimmedID),
|
||||
nil,
|
||||
"get",
|
||||
"item",
|
||||
trimmedID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output, &payload); err != nil {
|
||||
return nil, fmt.Errorf("decode bitwarden item %q: %w", trimmedID, err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) encodePayload(payload map[string]any) (string, error) {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode bitwarden payload: %w", err)
|
||||
}
|
||||
|
||||
output, err := s.execute("encode bitwarden payload", raw, "encode")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encoded := strings.TrimSpace(string(output))
|
||||
if encoded == "" {
|
||||
return "", errors.New("bitwarden CLI returned an empty encoded payload")
|
||||
}
|
||||
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) {
|
||||
output, err := runBitwardenCommand(s.command, s.debug, stdin, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func setBitwardenSecretPayload(payload map[string]any, serviceName, rawSecretName, secretName, label, secret string) {
|
||||
payload["type"] = 2
|
||||
payload["name"] = secretName
|
||||
payload["notes"] = strings.TrimSpace(label)
|
||||
payload["secureNote"] = map[string]any{"type": 0}
|
||||
payload["fields"] = []map[string]any{
|
||||
{
|
||||
"name": bitwardenSecretFieldName,
|
||||
"value": secret,
|
||||
"type": 1,
|
||||
},
|
||||
{
|
||||
"name": bitwardenServiceFieldName,
|
||||
"value": strings.TrimSpace(serviceName),
|
||||
"type": 0,
|
||||
},
|
||||
{
|
||||
"name": bitwardenSecretNameFieldName,
|
||||
"value": strings.TrimSpace(rawSecretName),
|
||||
"type": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readBitwardenSecret(payload map[string]any) (string, bool) {
|
||||
return readBitwardenField(payload, bitwardenSecretFieldName)
|
||||
}
|
||||
|
||||
func readBitwardenField(payload map[string]any, fieldName string) (string, bool) {
|
||||
rawFields, ok := payload["fields"]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
fields, ok := rawFields.([]any)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, rawField := range fields {
|
||||
field, ok := rawField.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
name, _ := field["name"].(string)
|
||||
if strings.TrimSpace(name) != strings.TrimSpace(fieldName) {
|
||||
continue
|
||||
}
|
||||
|
||||
value, ok := field["value"].(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return value, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName string) bool {
|
||||
markedService, ok := readBitwardenField(payload, bitwardenServiceFieldName)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
markedSecretName, ok := readBitwardenField(payload, bitwardenSecretNameFieldName)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.TrimSpace(markedService) == strings.TrimSpace(serviceName) &&
|
||||
strings.TrimSpace(markedSecretName) == strings.TrimSpace(secretName)
|
||||
}
|
||||
|
||||
func runBitwardenCommand(command string, debug bool, stdin []byte, args ...string) ([]byte, error) {
|
||||
if debug {
|
||||
logBitwardenCommand(command, args...)
|
||||
}
|
||||
return runBitwardenCLI(command, stdin, args...)
|
||||
}
|
||||
|
||||
func runBitwardenInteractiveCommand(
|
||||
command string,
|
||||
debug bool,
|
||||
stdin io.Reader,
|
||||
stdout, stderr io.Writer,
|
||||
args ...string,
|
||||
) ([]byte, error) {
|
||||
if debug {
|
||||
logBitwardenCommand(command, args...)
|
||||
}
|
||||
return runBitwardenInteractiveCLI(command, stdin, stdout, stderr, args...)
|
||||
}
|
||||
|
||||
func isBitwardenDebugEnabled(explicit bool) bool {
|
||||
if explicit {
|
||||
return true
|
||||
}
|
||||
|
||||
raw, ok := os.LookupEnv(bitwardenDebugEnvName)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func logBitwardenCommand(command string, args ...string) {
|
||||
writer := bitwardenDebugOutput
|
||||
if writer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
renderedArgs := sanitizeBitwardenDebugArgs(args)
|
||||
if len(renderedArgs) == 0 {
|
||||
_, _ = fmt.Fprintf(writer, "[bitwarden debug] %s\n", strings.TrimSpace(command))
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(
|
||||
writer,
|
||||
"[bitwarden debug] %s %s\n",
|
||||
strings.TrimSpace(command),
|
||||
strings.Join(renderedArgs, " "),
|
||||
)
|
||||
}
|
||||
|
||||
func sanitizeBitwardenDebugArgs(args []string) []string {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rendered := make([]string, len(args))
|
||||
for idx, arg := range args {
|
||||
rendered[idx] = strings.TrimSpace(arg)
|
||||
}
|
||||
|
||||
if len(rendered) >= 3 && rendered[0] == "create" && rendered[1] == "item" {
|
||||
rendered[2] = "<redacted>"
|
||||
}
|
||||
if len(rendered) >= 4 && rendered[0] == "edit" && rendered[1] == "item" {
|
||||
rendered[3] = "<redacted>"
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
stopLoader := startBitwardenLoaderFunc()
|
||||
defer stopLoader()
|
||||
|
||||
cmd := exec.Command(command, args...)
|
||||
if stdin != nil {
|
||||
cmd.Stdin = bytes.NewReader(stdin)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, normalizeBitwardenExecutionError(err, stderr.String(), stdout.String())
|
||||
}
|
||||
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
func executeBitwardenCLIInteractive(
|
||||
command string,
|
||||
stdin io.Reader,
|
||||
stdout, stderr io.Writer,
|
||||
args ...string,
|
||||
) ([]byte, error) {
|
||||
cmd := exec.Command(command, args...)
|
||||
if stdin != nil {
|
||||
cmd.Stdin = stdin
|
||||
}
|
||||
|
||||
var stdoutBuffer bytes.Buffer
|
||||
if stdout == nil {
|
||||
cmd.Stdout = &stdoutBuffer
|
||||
} else {
|
||||
cmd.Stdout = io.MultiWriter(stdout, &stdoutBuffer)
|
||||
}
|
||||
|
||||
var stderrBuffer bytes.Buffer
|
||||
if stderr == nil {
|
||||
cmd.Stderr = &stderrBuffer
|
||||
} else {
|
||||
cmd.Stderr = io.MultiWriter(stderr, &stderrBuffer)
|
||||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, normalizeBitwardenExecutionError(err, stderrBuffer.String(), stdoutBuffer.String())
|
||||
}
|
||||
|
||||
return stdoutBuffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func startBitwardenLoader() func() {
|
||||
if !shouldShowBitwardenLoader() {
|
||||
return func() {}
|
||||
}
|
||||
if !bitwardenLoaderActive.CompareAndSwap(false, true) {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(stopped)
|
||||
|
||||
ticker := time.NewTicker(bitwardenLoaderInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
phase := 0
|
||||
for {
|
||||
_, _ = fmt.Fprint(os.Stdout, bitwardenLoaderFrame(phase))
|
||||
phase++
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
_, _ = fmt.Fprint(os.Stdout, bitwardenLoaderClearLine)
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var stopOnce sync.Once
|
||||
return func() {
|
||||
stopOnce.Do(func() {
|
||||
close(done)
|
||||
<-stopped
|
||||
bitwardenLoaderActive.Store(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func shouldShowBitwardenLoader() bool {
|
||||
term := strings.TrimSpace(os.Getenv("TERM"))
|
||||
if term == "" || strings.EqualFold(term, "dumb") {
|
||||
return false
|
||||
}
|
||||
|
||||
info, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return (info.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
func bitwardenLoaderFrame(phase int) string {
|
||||
chars := []rune(bitwardenLoaderMessage)
|
||||
if len(chars) == 0 {
|
||||
return bitwardenLoaderClearLine
|
||||
}
|
||||
|
||||
waveIndex := phase % len(chars)
|
||||
if waveIndex < 0 {
|
||||
waveIndex += len(chars)
|
||||
}
|
||||
|
||||
var frame strings.Builder
|
||||
frame.Grow(len(chars)*14 + len(bitwardenLoaderClearLine) + len(bitwardenLoaderResetColor))
|
||||
frame.WriteString(bitwardenLoaderClearLine)
|
||||
|
||||
for idx, char := range chars {
|
||||
frame.WriteString(bitwardenLoaderColorForIndex(idx, waveIndex, len(chars)))
|
||||
frame.WriteRune(char)
|
||||
}
|
||||
|
||||
frame.WriteString(bitwardenLoaderResetColor)
|
||||
return frame.String()
|
||||
}
|
||||
|
||||
func bitwardenLoaderColorForIndex(idx, waveIndex, length int) string {
|
||||
if length <= 1 {
|
||||
return bitwardenLoaderColorFocus
|
||||
}
|
||||
|
||||
distance := idx - waveIndex
|
||||
if distance < 0 {
|
||||
distance = -distance
|
||||
}
|
||||
if wrapped := length - distance; wrapped < distance {
|
||||
distance = wrapped
|
||||
}
|
||||
|
||||
switch distance {
|
||||
case 0:
|
||||
return bitwardenLoaderColorFocus
|
||||
case 1:
|
||||
return bitwardenLoaderColorWave
|
||||
default:
|
||||
return bitwardenLoaderColorBase
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeBitwardenExecutionError(err error, stderrText, stdoutText string) error {
|
||||
detail := sanitizeBitwardenErrorDetail(stderrText, stdoutText)
|
||||
classification := classifyBitwardenError(detail)
|
||||
if classification == nil {
|
||||
classification = ErrBWUnavailable
|
||||
}
|
||||
|
||||
wrapped := errors.Join(classification, err)
|
||||
if strings.TrimSpace(detail) == "" {
|
||||
return wrapped
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %s", wrapped, detail)
|
||||
}
|
||||
|
||||
func sanitizeBitwardenErrorDetail(stderrText, stdoutText string) string {
|
||||
raw := strings.TrimSpace(stderrText)
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(stdoutText)
|
||||
}
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(raw, "\n")
|
||||
cleaned := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.HasPrefix(trimmed, "at ") ||
|
||||
strings.HasPrefix(lower, "node:internal") ||
|
||||
strings.HasPrefix(lower, "internal/") ||
|
||||
strings.HasPrefix(lower, "npm ") {
|
||||
continue
|
||||
}
|
||||
|
||||
cleaned = append(cleaned, trimmed)
|
||||
}
|
||||
|
||||
if len(cleaned) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(cleaned) == 1 {
|
||||
return cleaned[0]
|
||||
}
|
||||
|
||||
return cleaned[0] + " | " + cleaned[1]
|
||||
}
|
||||
|
||||
func classifyBitwardenError(detail string) error {
|
||||
lower := strings.ToLower(strings.TrimSpace(detail))
|
||||
|
||||
switch {
|
||||
case strings.Contains(lower, "not logged in"), strings.Contains(lower, "unauthenticated"):
|
||||
return ErrBWNotLoggedIn
|
||||
case strings.Contains(lower, "vault is locked"), strings.Contains(lower, "is locked"):
|
||||
return ErrBWLocked
|
||||
case strings.Contains(lower, "failed to fetch"),
|
||||
strings.Contains(lower, "econnrefused"),
|
||||
strings.Contains(lower, "etimedout"),
|
||||
strings.Contains(lower, "unable to connect"),
|
||||
strings.Contains(lower, "network"):
|
||||
return ErrBWUnavailable
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
378
secretstore/bitwarden_cache.go
Normal file
378
secretstore/bitwarden_cache.go
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hkdf"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
bitwardenCacheEnvName = "MCP_FRAMEWORK_BITWARDEN_CACHE"
|
||||
defaultBitwardenCacheTTL = 10 * time.Minute
|
||||
bitwardenCacheFormatVersion = 1
|
||||
bitwardenCacheAlgorithm = "AES-256-GCM"
|
||||
bitwardenCacheDirName = "bitwarden-cache"
|
||||
bitwardenCacheSalt = "mcp-framework bitwarden cache salt v1"
|
||||
bitwardenCacheInfo = "mcp-framework bitwarden cache v1"
|
||||
bitwardenCacheEncryptionInfo = "mcp-framework bitwarden cache encryption v1"
|
||||
bitwardenCacheEntryIDInfo = "mcp-framework bitwarden cache entry id v1"
|
||||
bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1"
|
||||
)
|
||||
|
||||
var bitwardenUserCacheDir = os.UserCacheDir
|
||||
|
||||
type bitwardenCacheOptions struct {
|
||||
ServiceName string
|
||||
Session string
|
||||
TTL time.Duration
|
||||
Now func() time.Time
|
||||
CacheDir string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type bitwardenCache struct {
|
||||
mu sync.Mutex
|
||||
enabled bool
|
||||
serviceName string
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
cacheDir string
|
||||
encryptionKey []byte
|
||||
entryIDKey []byte
|
||||
memory map[string]bitwardenCacheMemoryEntry
|
||||
}
|
||||
|
||||
type bitwardenCacheMemoryEntry struct {
|
||||
value string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type bitwardenCachePlaintext struct {
|
||||
Version int `json:"version"`
|
||||
ServiceName string `json:"service_name"`
|
||||
SecretName string `json:"secret_name"`
|
||||
ScopedName string `json:"scoped_name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type bitwardenCacheEnvelope struct {
|
||||
Version int `json:"version"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
Nonce string `json:"nonce"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
}
|
||||
|
||||
func newBitwardenCache(options bitwardenCacheOptions) *bitwardenCache {
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
ttl := options.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = defaultBitwardenCacheTTL
|
||||
}
|
||||
|
||||
cache := &bitwardenCache{
|
||||
enabled: options.Enabled,
|
||||
serviceName: strings.TrimSpace(options.ServiceName),
|
||||
ttl: ttl,
|
||||
now: now,
|
||||
cacheDir: strings.TrimSpace(options.CacheDir),
|
||||
memory: map[string]bitwardenCacheMemoryEntry{},
|
||||
}
|
||||
if !cache.enabled {
|
||||
return cache
|
||||
}
|
||||
|
||||
session := strings.TrimSpace(options.Session)
|
||||
if session == "" {
|
||||
return cache
|
||||
}
|
||||
masterKey, err := hkdf.Key(sha256.New, []byte(session), []byte(bitwardenCacheSalt), bitwardenCacheInfo, 32)
|
||||
if err != nil {
|
||||
return cache
|
||||
}
|
||||
cache.encryptionKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEncryptionInfo, 32)
|
||||
if err != nil {
|
||||
cache.encryptionKey = nil
|
||||
return cache
|
||||
}
|
||||
cache.entryIDKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEntryIDInfo, 32)
|
||||
if err != nil {
|
||||
cache.encryptionKey = nil
|
||||
cache.entryIDKey = nil
|
||||
return cache
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) load(secretName, scopedName string) (string, bool) {
|
||||
if value, ok := c.loadMemory(secretName, scopedName); ok {
|
||||
return value, true
|
||||
}
|
||||
return c.loadDisk(secretName, scopedName)
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) store(secretName, scopedName, value string) {
|
||||
if c == nil || !c.enabled {
|
||||
return
|
||||
}
|
||||
c.storeMemory(secretName, scopedName, value)
|
||||
c.storeDisk(secretName, scopedName, value)
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) invalidate(secretName, scopedName string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
key := c.memoryKey(secretName, scopedName)
|
||||
c.mu.Lock()
|
||||
delete(c.memory, key)
|
||||
c.mu.Unlock()
|
||||
if path, ok := c.entryPath(secretName, scopedName); ok {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) loadMemory(secretName, scopedName string) (string, bool) {
|
||||
if c == nil || !c.enabled {
|
||||
return "", false
|
||||
}
|
||||
key := c.memoryKey(secretName, scopedName)
|
||||
now := c.now()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
entry, ok := c.memory[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if !entry.expiresAt.After(now) {
|
||||
delete(c.memory, key)
|
||||
return "", false
|
||||
}
|
||||
return entry.value, true
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) storeMemory(secretName, scopedName, value string) {
|
||||
key := c.memoryKey(secretName, scopedName)
|
||||
c.mu.Lock()
|
||||
c.memory[key] = bitwardenCacheMemoryEntry{
|
||||
value: value,
|
||||
expiresAt: c.now().Add(c.ttl),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) loadDisk(secretName, scopedName string) (string, bool) {
|
||||
path, ok := c.entryPath(secretName, scopedName)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var envelope bitwardenCacheEnvelope
|
||||
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return "", false
|
||||
}
|
||||
plaintext, err := c.decryptEnvelope(secretName, scopedName, envelope)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return "", false
|
||||
}
|
||||
if plaintext.Version != bitwardenCacheFormatVersion ||
|
||||
plaintext.ServiceName != c.serviceName ||
|
||||
plaintext.SecretName != strings.TrimSpace(secretName) ||
|
||||
plaintext.ScopedName != strings.TrimSpace(scopedName) ||
|
||||
!plaintext.ExpiresAt.After(c.now()) {
|
||||
_ = os.Remove(path)
|
||||
return "", false
|
||||
}
|
||||
c.storeMemory(secretName, scopedName, plaintext.Value)
|
||||
return plaintext.Value, true
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) storeDisk(secretName, scopedName, value string) {
|
||||
if c.cacheDir == "" || len(c.encryptionKey) == 0 || len(c.entryIDKey) == 0 {
|
||||
return
|
||||
}
|
||||
path, ok := c.entryPath(secretName, scopedName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Chmod(filepath.Dir(path), 0o700)
|
||||
|
||||
now := c.now()
|
||||
plaintext := bitwardenCachePlaintext{
|
||||
Version: bitwardenCacheFormatVersion,
|
||||
ServiceName: c.serviceName,
|
||||
SecretName: strings.TrimSpace(secretName),
|
||||
ScopedName: strings.TrimSpace(scopedName),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(c.ttl),
|
||||
Value: value,
|
||||
}
|
||||
envelope, err := c.encryptPlaintext(secretName, scopedName, plaintext)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "bitwarden-cache-*.tmp")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
_ = tmp.Chmod(0o600)
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Chmod(path, 0o600)
|
||||
cleanup = false
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) encryptPlaintext(secretName, scopedName string, plaintext bitwardenCachePlaintext) (bitwardenCacheEnvelope, error) {
|
||||
raw, err := json.Marshal(plaintext)
|
||||
if err != nil {
|
||||
return bitwardenCacheEnvelope{}, err
|
||||
}
|
||||
block, err := aes.NewCipher(c.encryptionKey)
|
||||
if err != nil {
|
||||
return bitwardenCacheEnvelope{}, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return bitwardenCacheEnvelope{}, err
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return bitwardenCacheEnvelope{}, err
|
||||
}
|
||||
ciphertext := aead.Seal(nil, nonce, raw, c.additionalData(secretName, scopedName))
|
||||
return bitwardenCacheEnvelope{
|
||||
Version: bitwardenCacheFormatVersion,
|
||||
Algorithm: bitwardenCacheAlgorithm,
|
||||
Nonce: base64.StdEncoding.EncodeToString(nonce),
|
||||
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) decryptEnvelope(secretName, scopedName string, envelope bitwardenCacheEnvelope) (bitwardenCachePlaintext, error) {
|
||||
if envelope.Version != bitwardenCacheFormatVersion || envelope.Algorithm != bitwardenCacheAlgorithm {
|
||||
return bitwardenCachePlaintext{}, errors.New("unsupported bitwarden cache envelope")
|
||||
}
|
||||
nonce, err := base64.StdEncoding.DecodeString(envelope.Nonce)
|
||||
if err != nil {
|
||||
return bitwardenCachePlaintext{}, err
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(envelope.Ciphertext)
|
||||
if err != nil {
|
||||
return bitwardenCachePlaintext{}, err
|
||||
}
|
||||
block, err := aes.NewCipher(c.encryptionKey)
|
||||
if err != nil {
|
||||
return bitwardenCachePlaintext{}, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return bitwardenCachePlaintext{}, err
|
||||
}
|
||||
raw, err := aead.Open(nil, nonce, ciphertext, c.additionalData(secretName, scopedName))
|
||||
if err != nil {
|
||||
return bitwardenCachePlaintext{}, err
|
||||
}
|
||||
var plaintext bitwardenCachePlaintext
|
||||
if err := json.Unmarshal(raw, &plaintext); err != nil {
|
||||
return bitwardenCachePlaintext{}, err
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) entryPath(secretName, scopedName string) (string, bool) {
|
||||
if c == nil || !c.enabled || c.cacheDir == "" || len(c.entryIDKey) == 0 {
|
||||
return "", false
|
||||
}
|
||||
mac := hmac.New(sha256.New, c.entryIDKey)
|
||||
_, _ = mac.Write([]byte(c.cacheContext(secretName, scopedName)))
|
||||
return filepath.Join(c.cacheDir, hex.EncodeToString(mac.Sum(nil))+".json"), true
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) memoryKey(secretName, scopedName string) string {
|
||||
return c.cacheContext(secretName, scopedName)
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) additionalData(secretName, scopedName string) []byte {
|
||||
return []byte(fmt.Sprintf(
|
||||
"mcp-framework bitwarden cache v1\nservice=%s\nsecret=%s\nscoped=%s",
|
||||
c.serviceName,
|
||||
strings.TrimSpace(secretName),
|
||||
strings.TrimSpace(scopedName),
|
||||
))
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) cacheContext(secretName, scopedName string) string {
|
||||
return fmt.Sprintf(
|
||||
"version=%d\nservice=%s\nsecret=%s\nscoped=%s\nscope=%s",
|
||||
bitwardenCacheFormatVersion,
|
||||
c.serviceName,
|
||||
strings.TrimSpace(secretName),
|
||||
strings.TrimSpace(scopedName),
|
||||
bitwardenCacheContextScope,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveBitwardenCacheDir(serviceName string) string {
|
||||
cacheRoot, err := bitwardenUserCacheDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(cacheRoot, strings.TrimSpace(serviceName), bitwardenCacheDirName)
|
||||
}
|
||||
|
||||
func bitwardenCacheDisabledByEnv() bool {
|
||||
raw, ok := os.LookupEnv(bitwardenCacheEnvName)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "0", "false", "no", "off", "disabled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
141
secretstore/bitwarden_cache_test.go
Normal file
141
secretstore/bitwarden_cache_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBitwardenCacheMemoryHit(t *testing.T) {
|
||||
cache := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v1",
|
||||
TTL: 10 * time.Minute,
|
||||
Now: func() time.Time { return time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) },
|
||||
CacheDir: t.TempDir(),
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
cache.store("api-token", "email-mcp/api-token", "secret-v1")
|
||||
got, ok := cache.loadMemory("api-token", "email-mcp/api-token")
|
||||
if !ok {
|
||||
t.Fatal("memory cache miss, want hit")
|
||||
}
|
||||
if got != "secret-v1" {
|
||||
t.Fatalf("memory cache value = %q, want secret-v1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenCacheDiskRoundTripIsEncrypted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC)
|
||||
cache := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v1",
|
||||
TTL: 10 * time.Minute,
|
||||
Now: func() time.Time { return now },
|
||||
CacheDir: dir,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
cache.store("api-token", "email-mcp/api-token", "secret-v1")
|
||||
|
||||
reopened := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v1",
|
||||
TTL: 10 * time.Minute,
|
||||
Now: func() time.Time { return now.Add(time.Minute) },
|
||||
CacheDir: dir,
|
||||
Enabled: true,
|
||||
})
|
||||
got, ok := reopened.loadDisk("api-token", "email-mcp/api-token")
|
||||
if !ok {
|
||||
t.Fatal("disk cache miss, want hit")
|
||||
}
|
||||
if got != "secret-v1" {
|
||||
t.Fatalf("disk cache value = %q, want secret-v1", got)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir cache dir: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("cache file count = %d, want 1", len(entries))
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile cache file: %v", err)
|
||||
}
|
||||
if bytes.Contains(data, []byte("secret-v1")) {
|
||||
t.Fatalf("cache file contains plaintext secret: %s", data)
|
||||
}
|
||||
if strings.Contains(entries[0].Name(), "api-token") {
|
||||
t.Fatalf("cache file name exposes secret name: %s", entries[0].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenCacheRejectsChangedSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC)
|
||||
cache := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v1",
|
||||
TTL: 10 * time.Minute,
|
||||
Now: func() time.Time { return now },
|
||||
CacheDir: dir,
|
||||
Enabled: true,
|
||||
})
|
||||
cache.store("api-token", "email-mcp/api-token", "secret-v1")
|
||||
|
||||
changed := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v2",
|
||||
TTL: 10 * time.Minute,
|
||||
Now: func() time.Time { return now.Add(time.Minute) },
|
||||
CacheDir: dir,
|
||||
Enabled: true,
|
||||
})
|
||||
if got, ok := changed.loadDisk("api-token", "email-mcp/api-token"); ok {
|
||||
t.Fatalf("disk cache hit with changed session = %q, want miss", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenCacheExpiresEntries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC)
|
||||
cache := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v1",
|
||||
TTL: time.Minute,
|
||||
Now: func() time.Time { return now },
|
||||
CacheDir: dir,
|
||||
Enabled: true,
|
||||
})
|
||||
cache.store("api-token", "email-mcp/api-token", "secret-v1")
|
||||
|
||||
expired := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v1",
|
||||
TTL: time.Minute,
|
||||
Now: func() time.Time { return now.Add(2 * time.Minute) },
|
||||
CacheDir: dir,
|
||||
Enabled: true,
|
||||
})
|
||||
if got, ok := expired.load("api-token", "email-mcp/api-token"); ok {
|
||||
t.Fatalf("expired cache hit = %q, want miss", got)
|
||||
}
|
||||
}
|
||||
|
||||
func withBitwardenUserCacheDir(t *testing.T, resolver func() (string, error)) {
|
||||
t.Helper()
|
||||
|
||||
previous := bitwardenUserCacheDir
|
||||
bitwardenUserCacheDir = resolver
|
||||
t.Cleanup(func() {
|
||||
bitwardenUserCacheDir = previous
|
||||
})
|
||||
}
|
||||
229
secretstore/bitwarden_session.go
Normal file
229
secretstore/bitwarden_session.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
bitwardenSessionFileName = "bw-session"
|
||||
bitwardenSharedSessionName = "mcp-framework"
|
||||
)
|
||||
|
||||
var bitwardenUserConfigDir = os.UserConfigDir
|
||||
|
||||
type BitwardenSessionOptions struct {
|
||||
ServiceName string
|
||||
}
|
||||
|
||||
type BitwardenLoginOptions struct {
|
||||
ServiceName string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
Stdin io.Reader
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
}
|
||||
|
||||
func SaveBitwardenSession(options BitwardenSessionOptions, session string) (string, error) {
|
||||
trimmedSession := strings.TrimSpace(session)
|
||||
if trimmedSession == "" {
|
||||
return "", errors.New("bitwarden session must not be empty")
|
||||
}
|
||||
|
||||
path, err := resolveBitwardenSessionPath(options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("create bitwarden session dir %q: %w", dir, err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("set bitwarden session dir permissions %q: %w", dir, err)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp(dir, "bw-session-*.tmp")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp bitwarden session in %q: %w", dir, err)
|
||||
}
|
||||
|
||||
tmpPath := tmpFile.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
_ = tmpFile.Close()
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := tmpFile.Chmod(0o600); err != nil {
|
||||
return "", fmt.Errorf("set bitwarden session temp file permissions %q: %w", tmpPath, err)
|
||||
}
|
||||
if _, err := tmpFile.WriteString(trimmedSession + "\n"); err != nil {
|
||||
return "", fmt.Errorf("write bitwarden session temp file %q: %w", tmpPath, err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return "", fmt.Errorf("close bitwarden session temp file %q: %w", tmpPath, err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return "", fmt.Errorf("replace bitwarden session file %q: %w", path, err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
return "", fmt.Errorf("set bitwarden session file permissions %q: %w", path, err)
|
||||
}
|
||||
|
||||
cleanup = false
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func LoadBitwardenSession(options BitwardenSessionOptions) (string, error) {
|
||||
path, err := resolveBitwardenSessionPath(options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", fmt.Errorf("read bitwarden session file %q: %w", path, err)
|
||||
}
|
||||
|
||||
session := strings.TrimSpace(string(data))
|
||||
if session == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) {
|
||||
if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
session, err := LoadBitwardenSession(options)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return false, err
|
||||
}
|
||||
// Service-specific file not found; try the shared file written by any MCP login.
|
||||
session, err = LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Setenv(bitwardenSessionEnvName, session); err != nil {
|
||||
return false, fmt.Errorf("set %s from persisted bitwarden session: %w", bitwardenSessionEnvName, err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
|
||||
serviceName := strings.TrimSpace(options.ServiceName)
|
||||
if serviceName == "" {
|
||||
return "", errors.New("service name must not be empty")
|
||||
}
|
||||
|
||||
command := strings.TrimSpace(options.BitwardenCommand)
|
||||
if command == "" {
|
||||
command = defaultBitwardenCommand
|
||||
}
|
||||
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
||||
|
||||
stdin := options.Stdin
|
||||
if stdin == nil {
|
||||
stdin = os.Stdin
|
||||
}
|
||||
stdout := options.Stdout
|
||||
if stdout == nil {
|
||||
stdout = os.Stdout
|
||||
}
|
||||
stderr := options.Stderr
|
||||
if stderr == nil {
|
||||
stderr = os.Stderr
|
||||
}
|
||||
|
||||
status, err := readBitwardenStatus(command, debugEnabled)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch status {
|
||||
case "unauthenticated":
|
||||
if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil {
|
||||
return "", fmt.Errorf("login to bitwarden CLI: %w", err)
|
||||
}
|
||||
case "unlocked":
|
||||
// Vault is already unlocked. Reuse an existing session to avoid calling
|
||||
// bw unlock again, which would generate a new token and invalidate the
|
||||
// tokens held by other running MCP processes.
|
||||
if existing := loadAnyBitwardenSession(); existing != "" {
|
||||
if err := os.Setenv(bitwardenSessionEnvName, existing); err != nil {
|
||||
return "", fmt.Errorf("set %s from existing session: %w", bitwardenSessionEnvName, err)
|
||||
}
|
||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, existing); err != nil {
|
||||
return "", fmt.Errorf("persist bitwarden session: %w", err)
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
case "locked":
|
||||
default:
|
||||
return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status)
|
||||
}
|
||||
|
||||
unlockOutput, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, nil, stderr, "unlock", "--raw")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unlock bitwarden vault: %w", err)
|
||||
}
|
||||
|
||||
session := strings.TrimSpace(string(unlockOutput))
|
||||
if session == "" {
|
||||
return "", errors.New("bitwarden CLI returned an empty session after unlock")
|
||||
}
|
||||
|
||||
if err := os.Setenv(bitwardenSessionEnvName, session); err != nil {
|
||||
return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err)
|
||||
}
|
||||
|
||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, session); err != nil {
|
||||
return "", fmt.Errorf("persist bitwarden session: %w", err)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// loadAnyBitwardenSession looks for an existing valid session in: the process
|
||||
// environment, then the shared file written by any MCP login.
|
||||
func loadAnyBitwardenSession() string {
|
||||
if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" {
|
||||
return strings.TrimSpace(session)
|
||||
}
|
||||
if session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}); err == nil {
|
||||
return session
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) {
|
||||
serviceName := strings.TrimSpace(options.ServiceName)
|
||||
if serviceName == "" {
|
||||
return "", errors.New("service name must not be empty")
|
||||
}
|
||||
|
||||
userConfigDir, err := bitwardenUserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve user config dir for bitwarden session: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(userConfigDir, serviceName, bitwardenSessionFileName), nil
|
||||
}
|
||||
337
secretstore/bitwarden_session_test.go
Normal file
337
secretstore/bitwarden_session_test.go
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoginBitwardenRunsInteractiveFlowAndPersistsSession(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
if len(args) == 1 && args[0] == "status" {
|
||||
return []byte(`{"status":"unauthenticated"}`), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected args: %v", args)
|
||||
})
|
||||
|
||||
var calls [][]string
|
||||
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
|
||||
calls = append(calls, slices.Clone(args))
|
||||
switch {
|
||||
case len(args) == 1 && args[0] == "login":
|
||||
return nil, nil
|
||||
case len(args) == 2 && args[0] == "unlock" && args[1] == "--raw":
|
||||
return []byte("persisted-session\n"), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected interactive args: %v", args)
|
||||
}
|
||||
})
|
||||
|
||||
session, err := LoginBitwarden(BitwardenLoginOptions{
|
||||
ServiceName: "email-mcp",
|
||||
BitwardenCommand: "bw",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginBitwarden returned error: %v", err)
|
||||
}
|
||||
if session != "persisted-session" {
|
||||
t.Fatalf("session = %q, want persisted-session", session)
|
||||
}
|
||||
if got := os.Getenv("BW_SESSION"); got != "persisted-session" {
|
||||
t.Fatalf("BW_SESSION = %q, want persisted-session", got)
|
||||
}
|
||||
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("interactive call count = %d, want 2", len(calls))
|
||||
}
|
||||
if !slices.Equal(calls[0], []string{"login"}) {
|
||||
t.Fatalf("interactive call #1 args = %v, want [login]", calls[0])
|
||||
}
|
||||
if !slices.Equal(calls[1], []string{"unlock", "--raw"}) {
|
||||
t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1])
|
||||
}
|
||||
|
||||
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err)
|
||||
}
|
||||
if persisted != "persisted-session" {
|
||||
t.Fatalf("shared persisted session = %q, want persisted-session", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginBitwardenSkipsInteractiveLoginWhenAlreadyAuthenticated(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
if len(args) == 1 && args[0] == "status" {
|
||||
return []byte(`{"status":"locked"}`), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected args: %v", args)
|
||||
})
|
||||
|
||||
var calls [][]string
|
||||
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
|
||||
calls = append(calls, slices.Clone(args))
|
||||
if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" {
|
||||
return []byte("session-locked\n"), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected interactive args: %v", args)
|
||||
})
|
||||
|
||||
session, err := LoginBitwarden(BitwardenLoginOptions{
|
||||
ServiceName: "email-mcp",
|
||||
BitwardenCommand: "bw",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginBitwarden returned error: %v", err)
|
||||
}
|
||||
if session != "session-locked" {
|
||||
t.Fatalf("session = %q, want session-locked", session)
|
||||
}
|
||||
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("interactive call count = %d, want 1", len(calls))
|
||||
}
|
||||
if !slices.Equal(calls[0], []string{"unlock", "--raw"}) {
|
||||
t.Fatalf("interactive call args = %v, want [unlock --raw]", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenSessionEnvLoadsFromPersistedSession(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
|
||||
path, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat persisted session file: %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("session file mode = %o, want 600", got)
|
||||
}
|
||||
|
||||
loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err)
|
||||
}
|
||||
if !loaded {
|
||||
t.Fatal("expected session to be loaded from persisted file")
|
||||
}
|
||||
if got := os.Getenv("BW_SESSION"); got != "persisted-session" {
|
||||
t.Fatalf("BW_SESSION = %q, want persisted-session", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenSessionEnvDoesNotOverrideExistingValue(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "from-env")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
|
||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session"); err != nil {
|
||||
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err)
|
||||
}
|
||||
if loaded {
|
||||
t.Fatal("expected existing BW_SESSION to be kept")
|
||||
}
|
||||
if got := os.Getenv("BW_SESSION"); got != "from-env" {
|
||||
t.Fatalf("BW_SESSION = %q, want from-env", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenBitwardenCLILoadsPersistedSessionWhenEnvironmentIsMissing(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
|
||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-open-session"); err != nil {
|
||||
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
||||
}
|
||||
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
if _, ok := store.(*bitwardenStore); !ok {
|
||||
t.Fatalf("store type = %T, want *bitwardenStore", store)
|
||||
}
|
||||
if got := os.Getenv("BW_SESSION"); got != "persisted-open-session" {
|
||||
t.Fatalf("BW_SESSION = %q, want persisted-open-session", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBitwardenSessionReturnsNotFoundWhenFileMissing(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
|
||||
_, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginBitwardenPersistsToSharedFileAfterUnlock(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
if len(args) == 1 && args[0] == "status" {
|
||||
return []byte(`{"status":"locked"}`), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected args: %v", args)
|
||||
})
|
||||
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
|
||||
if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" {
|
||||
return []byte("shared-session\n"), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected interactive args: %v", args)
|
||||
})
|
||||
|
||||
if _, err := LoginBitwarden(BitwardenLoginOptions{
|
||||
ServiceName: "graylog-mcp",
|
||||
BitwardenCommand: "bw",
|
||||
}); err != nil {
|
||||
t.Fatalf("LoginBitwarden returned error: %v", err)
|
||||
}
|
||||
|
||||
shared, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err)
|
||||
}
|
||||
if shared != "shared-session" {
|
||||
t.Fatalf("shared session = %q, want shared-session", shared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithEnvSession(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "existing-session")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
if len(args) == 1 && args[0] == "status" {
|
||||
return []byte(`{"status":"unlocked"}`), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected args: %v", args)
|
||||
})
|
||||
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("bw unlock must not be called when vault is already unlocked with a session: %v", args)
|
||||
})
|
||||
|
||||
session, err := LoginBitwarden(BitwardenLoginOptions{
|
||||
ServiceName: "email-mcp",
|
||||
BitwardenCommand: "bw",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginBitwarden returned error: %v", err)
|
||||
}
|
||||
if session != "existing-session" {
|
||||
t.Fatalf("session = %q, want existing-session", session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithSharedSession(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
|
||||
// graylog-mcp logged in earlier and wrote to the shared file
|
||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "graylog-session"); err != nil {
|
||||
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
||||
}
|
||||
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
if len(args) == 1 && args[0] == "status" {
|
||||
return []byte(`{"status":"unlocked"}`), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected args: %v", args)
|
||||
})
|
||||
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("bw unlock must not be called when shared session exists: %v", args)
|
||||
})
|
||||
|
||||
session, err := LoginBitwarden(BitwardenLoginOptions{
|
||||
ServiceName: "email-mcp",
|
||||
BitwardenCommand: "bw",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginBitwarden returned error: %v", err)
|
||||
}
|
||||
if session != "graylog-session" {
|
||||
t.Fatalf("session = %q, want graylog-session (from shared file)", session)
|
||||
}
|
||||
|
||||
// The shared session must also be persisted to the service-specific file
|
||||
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBitwardenSession returned error: %v", err)
|
||||
}
|
||||
if persisted != "graylog-session" {
|
||||
t.Fatalf("email-mcp persisted session = %q, want graylog-session", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func withBitwardenInteractiveRunner(
|
||||
t *testing.T,
|
||||
runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error),
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
previous := runBitwardenInteractiveCLI
|
||||
runBitwardenInteractiveCLI = runner
|
||||
t.Cleanup(func() {
|
||||
runBitwardenInteractiveCLI = previous
|
||||
})
|
||||
}
|
||||
|
||||
func withBitwardenUserConfigDir(t *testing.T, resolver func() (string, error)) {
|
||||
t.Helper()
|
||||
|
||||
previous := bitwardenUserConfigDir
|
||||
bitwardenUserConfigDir = resolver
|
||||
t.Cleanup(func() {
|
||||
bitwardenUserConfigDir = previous
|
||||
})
|
||||
}
|
||||
1116
secretstore/bitwarden_test.go
Normal file
1116
secretstore/bitwarden_test.go
Normal file
File diff suppressed because it is too large
Load diff
127
secretstore/manifest_open.go
Normal file
127
secretstore/manifest_open.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
type ManifestLoader func(startDir string) (manifest.File, string, error)
|
||||
|
||||
type ExecutableResolver func() (string, error)
|
||||
|
||||
type OpenFromManifestOptions struct {
|
||||
ServiceName string
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
DisableBitwardenCache bool
|
||||
Shell string
|
||||
ManifestLoader ManifestLoader
|
||||
ExecutableResolver ExecutableResolver
|
||||
}
|
||||
|
||||
func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
|
||||
manifestPolicy, err := resolveManifestPolicy(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Open(Options{
|
||||
ServiceName: options.ServiceName,
|
||||
BackendPolicy: manifestPolicy.Policy,
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: strings.TrimSpace(options.BitwardenCommand),
|
||||
BitwardenDebug: options.BitwardenDebug,
|
||||
DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, manifestPolicy.BitwardenCache),
|
||||
Shell: strings.TrimSpace(options.Shell),
|
||||
})
|
||||
}
|
||||
|
||||
func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolicy, error) {
|
||||
resolution, err := resolveManifestPolicy(options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolution.Policy, nil
|
||||
}
|
||||
|
||||
type manifestPolicyResolution struct {
|
||||
Policy BackendPolicy
|
||||
Source string
|
||||
BitwardenCache bool
|
||||
}
|
||||
|
||||
func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) {
|
||||
manifestLoader := options.ManifestLoader
|
||||
if manifestLoader == nil {
|
||||
manifestLoader = manifest.LoadDefault
|
||||
}
|
||||
|
||||
executableResolver := options.ExecutableResolver
|
||||
if executableResolver == nil {
|
||||
executableResolver = os.Executable
|
||||
}
|
||||
|
||||
executablePath, err := executableResolver()
|
||||
if err != nil {
|
||||
return manifestPolicyResolution{}, fmt.Errorf("resolve executable path for manifest lookup: %w", err)
|
||||
}
|
||||
|
||||
startDir := filepath.Dir(strings.TrimSpace(executablePath))
|
||||
if strings.TrimSpace(startDir) == "" {
|
||||
startDir = "."
|
||||
}
|
||||
|
||||
file, manifestPath, err := manifestLoader(startDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return manifestPolicyResolution{
|
||||
Policy: BackendAuto,
|
||||
Source: "",
|
||||
BitwardenCache: true,
|
||||
}, nil
|
||||
}
|
||||
return manifestPolicyResolution{}, fmt.Errorf("load runtime manifest from %q: %w", startDir, err)
|
||||
}
|
||||
|
||||
bitwardenCache := true
|
||||
if file.SecretStore.BitwardenCache != nil {
|
||||
bitwardenCache = *file.SecretStore.BitwardenCache
|
||||
}
|
||||
|
||||
if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" {
|
||||
return manifestPolicyResolution{
|
||||
Policy: BackendAuto,
|
||||
Source: strings.TrimSpace(manifestPath),
|
||||
BitwardenCache: bitwardenCache,
|
||||
}, nil
|
||||
}
|
||||
|
||||
policy, err := normalizeBackendPolicy(BackendPolicy(file.SecretStore.BackendPolicy))
|
||||
if err != nil {
|
||||
return manifestPolicyResolution{}, fmt.Errorf(
|
||||
"invalid secret_store.backend_policy in manifest %q: %w",
|
||||
strings.TrimSpace(manifestPath),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return manifestPolicyResolution{
|
||||
Policy: policy,
|
||||
Source: strings.TrimSpace(manifestPath),
|
||||
BitwardenCache: bitwardenCache,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool {
|
||||
return runtimeDisabled || !manifestEnabled
|
||||
}
|
||||
211
secretstore/manifest_open_test.go
Normal file
211
secretstore/manifest_open_test.go
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/99designs/keyring"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) {
|
||||
var gotStartDir string
|
||||
|
||||
store, err := OpenFromManifest(OpenFromManifestOptions{
|
||||
ServiceName: "email-mcp",
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
if name == "EMAIL_TOKEN" {
|
||||
return "from-env", true
|
||||
}
|
||||
return "", false
|
||||
},
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
gotStartDir = startDir
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenFromManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
wantDir := filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin")
|
||||
if gotStartDir != wantDir {
|
||||
t.Fatalf("manifest loader startDir = %q, want %q", gotStartDir, wantDir)
|
||||
}
|
||||
|
||||
value, err := store.GetSecret("EMAIL_TOKEN")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if value != "from-env" {
|
||||
t.Fatalf("GetSecret = %q, want from-env", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFromManifestFallsBackToAutoWhenManifestIsMissing(t *testing.T) {
|
||||
withKeyringHooks(t, nil, func(cfg keyring.Config) (keyring.Keyring, error) {
|
||||
t.Fatal("unexpected keyring open call")
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
store, err := OpenFromManifest(OpenFromManifestOptions{
|
||||
ServiceName: "email-mcp",
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
if name == "EMAIL_TOKEN" {
|
||||
return "env-token", true
|
||||
}
|
||||
return "", false
|
||||
},
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(string) (manifest.File, string, error) {
|
||||
return manifest.File{}, "", os.ErrNotExist
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenFromManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
value, err := store.GetSecret("EMAIL_TOKEN")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if value != "env-token" {
|
||||
t.Fatalf("GetSecret = %q, want env-token", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFromManifestReturnsExplicitErrorForInvalidManifestPolicy(t *testing.T) {
|
||||
_, err := OpenFromManifest(OpenFromManifestOptions{
|
||||
ServiceName: "email-mcp",
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{BackendPolicy: "totally-invalid"},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidBackendPolicy) {
|
||||
t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "secret_store.backend_policy") {
|
||||
t.Fatalf("error = %v, want manifest policy context", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), manifest.DefaultFile) {
|
||||
t.Fatalf("error = %v, want manifest path", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) {
|
||||
cacheDisabled := false
|
||||
resolution, err := resolveManifestPolicy(OpenFromManifestOptions{
|
||||
ServiceName: "email-mcp",
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{
|
||||
BackendPolicy: string(BackendBitwardenCLI),
|
||||
BitwardenCache: &cacheDisabled,
|
||||
},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveManifestPolicy returned error: %v", err)
|
||||
}
|
||||
if resolution.BitwardenCache {
|
||||
t.Fatal("resolution BitwardenCache = true, want false")
|
||||
}
|
||||
if resolution.Policy != BackendBitwardenCLI {
|
||||
t.Fatalf("resolution policy = %q, want %q", resolution.Policy, BackendBitwardenCLI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFromManifestAppliesBitwardenCacheDisable(t *testing.T) {
|
||||
withBitwardenSession(t)
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
||||
ID: "item-1",
|
||||
Name: "email-mcp/api-token",
|
||||
Secret: "secret-v1",
|
||||
MarkerService: "email-mcp",
|
||||
MarkerSecretName: "api-token",
|
||||
}
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
cacheDisabled := false
|
||||
store, err := OpenFromManifest(OpenFromManifestOptions{
|
||||
ServiceName: "email-mcp",
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{
|
||||
BackendPolicy: string(BackendBitwardenCLI),
|
||||
BitwardenCache: &cacheDisabled,
|
||||
},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenFromManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
value, err := store.GetSecret("api-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
|
||||
}
|
||||
if value != "secret-v1" {
|
||||
t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value)
|
||||
}
|
||||
}
|
||||
if fakeCLI.getItemCalls != 2 {
|
||||
t.Fatalf("bw get item count = %d, want 2 when manifest disables cache", fakeCLI.getItemCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) {
|
||||
execErr := errors.New("boom")
|
||||
_, err := OpenFromManifest(OpenFromManifestOptions{
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return "", execErr
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, execErr) {
|
||||
t.Fatalf("error = %v, want wrapped executable resolver error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFromManifestReturnsManifestLoaderError(t *testing.T) {
|
||||
loadErr := errors.New("cannot parse manifest")
|
||||
_, err := OpenFromManifest(OpenFromManifestOptions{
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(string) (manifest.File, string, error) {
|
||||
return manifest.File{}, "", loadErr
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, loadErr) {
|
||||
t.Fatalf("error = %v, want wrapped manifest loader error", err)
|
||||
}
|
||||
}
|
||||
214
secretstore/runtime.go
Normal file
214
secretstore/runtime.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const DefaultManifestSource = "default:auto (manifest not found)"
|
||||
|
||||
type DescribeRuntimeOptions struct {
|
||||
ServiceName string
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
DisableBitwardenCache bool
|
||||
CheckReady bool
|
||||
Shell string
|
||||
ManifestLoader ManifestLoader
|
||||
ExecutableResolver ExecutableResolver
|
||||
}
|
||||
|
||||
type RuntimeDescription struct {
|
||||
ManifestSource string
|
||||
DeclaredPolicy BackendPolicy
|
||||
EffectivePolicy BackendPolicy
|
||||
DisplayName string
|
||||
Ready bool
|
||||
ReadyError error
|
||||
}
|
||||
|
||||
type PreflightStatus string
|
||||
|
||||
const (
|
||||
PreflightStatusReady PreflightStatus = "ready"
|
||||
PreflightStatusFail PreflightStatus = "fail"
|
||||
)
|
||||
|
||||
type PreflightOptions = DescribeRuntimeOptions
|
||||
|
||||
type PreflightReport struct {
|
||||
Status PreflightStatus
|
||||
Summary string
|
||||
Remediation string
|
||||
Runtime RuntimeDescription
|
||||
}
|
||||
|
||||
func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) {
|
||||
resolution, err := resolveManifestPolicy(OpenFromManifestOptions{
|
||||
ServiceName: options.ServiceName,
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
DisableBitwardenCache: options.DisableBitwardenCache,
|
||||
Shell: options.Shell,
|
||||
ManifestLoader: options.ManifestLoader,
|
||||
ExecutableResolver: options.ExecutableResolver,
|
||||
})
|
||||
if err != nil {
|
||||
return RuntimeDescription{}, err
|
||||
}
|
||||
|
||||
desc := RuntimeDescription{
|
||||
ManifestSource: manifestSourceLabel(resolution.Source),
|
||||
DeclaredPolicy: resolution.Policy,
|
||||
EffectivePolicy: resolution.Policy,
|
||||
DisplayName: BackendDisplayName(resolution.Policy),
|
||||
}
|
||||
|
||||
store, openErr := Open(Options{
|
||||
ServiceName: options.ServiceName,
|
||||
BackendPolicy: resolution.Policy,
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
BitwardenDebug: options.BitwardenDebug,
|
||||
DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, resolution.BitwardenCache),
|
||||
Shell: options.Shell,
|
||||
})
|
||||
if openErr != nil {
|
||||
desc.Ready = false
|
||||
desc.ReadyError = openErr
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
desc.Ready = true
|
||||
if effective := EffectiveBackendPolicy(store); strings.TrimSpace(string(effective)) != "" {
|
||||
desc.EffectivePolicy = effective
|
||||
desc.DisplayName = BackendDisplayName(effective)
|
||||
}
|
||||
if options.CheckReady && desc.EffectivePolicy == BackendBitwardenCLI {
|
||||
if err := verifyBitwardenCLIReady(Options{
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
BitwardenDebug: options.BitwardenDebug,
|
||||
LookupEnv: options.LookupEnv,
|
||||
Shell: options.Shell,
|
||||
}); err != nil {
|
||||
desc.Ready = false
|
||||
desc.ReadyError = err
|
||||
}
|
||||
}
|
||||
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
func PreflightFromManifest(options PreflightOptions) (PreflightReport, error) {
|
||||
options.CheckReady = true
|
||||
desc, err := DescribeRuntime(options)
|
||||
if err != nil {
|
||||
return PreflightReport{}, err
|
||||
}
|
||||
|
||||
if desc.Ready {
|
||||
return PreflightReport{
|
||||
Status: PreflightStatusReady,
|
||||
Summary: "secret backend is ready",
|
||||
Runtime: desc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
summary, remediation := summarizePreflightFailure(desc.ReadyError)
|
||||
return PreflightReport{
|
||||
Status: PreflightStatusFail,
|
||||
Summary: summary,
|
||||
Remediation: remediation,
|
||||
Runtime: desc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BackendDisplayName(policy BackendPolicy) string {
|
||||
switch policy {
|
||||
case BackendBitwardenCLI:
|
||||
return "Bitwarden CLI"
|
||||
case BackendEnvOnly:
|
||||
return "Environment variables"
|
||||
case BackendKWalletOnly:
|
||||
return "KWallet"
|
||||
case BackendAuto:
|
||||
return "automatic backend selection"
|
||||
case BackendKeyringAny:
|
||||
return BackendName()
|
||||
default:
|
||||
trimmed := strings.TrimSpace(string(policy))
|
||||
if trimmed == "" {
|
||||
return "unknown backend"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func FormatBackendStatus(desc RuntimeDescription) string {
|
||||
source := manifestSourceLabel(desc.ManifestSource)
|
||||
display := strings.TrimSpace(desc.DisplayName)
|
||||
if display == "" {
|
||||
display = BackendDisplayName(desc.EffectivePolicy)
|
||||
}
|
||||
|
||||
effective := desc.EffectivePolicy
|
||||
if strings.TrimSpace(string(effective)) == "" {
|
||||
effective = desc.DeclaredPolicy
|
||||
}
|
||||
|
||||
parts := []string{
|
||||
fmt.Sprintf("declared=%s", normalizeStatusPolicy(desc.DeclaredPolicy)),
|
||||
fmt.Sprintf("effective=%s", normalizeStatusPolicy(effective)),
|
||||
fmt.Sprintf("display=%s", display),
|
||||
fmt.Sprintf("ready=%t", desc.Ready),
|
||||
fmt.Sprintf("source=%s", source),
|
||||
}
|
||||
if desc.ReadyError != nil {
|
||||
parts = append(parts, fmt.Sprintf("error=%s", strings.TrimSpace(desc.ReadyError.Error())))
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func summarizePreflightFailure(err error) (string, string) {
|
||||
if err == nil {
|
||||
return "secret backend is unavailable", ""
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, ErrBWNotLoggedIn):
|
||||
return "bitwarden login is required", strings.TrimSpace(err.Error())
|
||||
case errors.Is(err, ErrBWLocked):
|
||||
return "bitwarden vault is locked or BW_SESSION is missing", strings.TrimSpace(err.Error())
|
||||
case errors.Is(err, ErrBWUnavailable):
|
||||
return "bitwarden CLI is unavailable", strings.TrimSpace(err.Error())
|
||||
case errors.Is(err, ErrBackendUnavailable):
|
||||
return "secret backend is unavailable", strings.TrimSpace(err.Error())
|
||||
default:
|
||||
return "secret backend preflight failed", strings.TrimSpace(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func manifestSourceLabel(source string) string {
|
||||
trimmed := strings.TrimSpace(source)
|
||||
if trimmed == "" {
|
||||
return DefaultManifestSource
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func normalizeStatusPolicy(policy BackendPolicy) string {
|
||||
trimmed := strings.TrimSpace(string(policy))
|
||||
if trimmed == "" {
|
||||
return string(BackendAuto)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
145
secretstore/runtime_test.go
Normal file
145
secretstore/runtime_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) {
|
||||
desc, err := DescribeRuntime(DescribeRuntimeOptions{
|
||||
ServiceName: "graylog-mcp",
|
||||
LookupEnv: func(string) (string, bool) { return "", false },
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeRuntime returned error: %v", err)
|
||||
}
|
||||
|
||||
if desc.ManifestSource == "" {
|
||||
t.Fatal("ManifestSource should not be empty")
|
||||
}
|
||||
if desc.DeclaredPolicy != BackendEnvOnly {
|
||||
t.Fatalf("DeclaredPolicy = %q, want %q", desc.DeclaredPolicy, BackendEnvOnly)
|
||||
}
|
||||
if desc.EffectivePolicy != BackendEnvOnly {
|
||||
t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendEnvOnly)
|
||||
}
|
||||
if desc.DisplayName == "" {
|
||||
t.Fatal("DisplayName should not be empty")
|
||||
}
|
||||
if !desc.Ready {
|
||||
t.Fatalf("Ready = %v, want true", desc.Ready)
|
||||
}
|
||||
if desc.ReadyError != nil {
|
||||
t.Fatalf("ReadyError = %v, want nil", desc.ReadyError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeRuntimeDoesNotProbeBitwardenByDefault(t *testing.T) {
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
return nil, errors.New("unexpected bitwarden invocation")
|
||||
})
|
||||
|
||||
desc, err := DescribeRuntime(DescribeRuntimeOptions{
|
||||
ServiceName: "graylog-mcp",
|
||||
Shell: "fish",
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeRuntime returned error: %v", err)
|
||||
}
|
||||
|
||||
if desc.DeclaredPolicy != BackendBitwardenCLI {
|
||||
t.Fatalf("DeclaredPolicy = %q, want %q", desc.DeclaredPolicy, BackendBitwardenCLI)
|
||||
}
|
||||
if desc.EffectivePolicy != BackendBitwardenCLI {
|
||||
t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendBitwardenCLI)
|
||||
}
|
||||
if !desc.Ready {
|
||||
t.Fatalf("Ready = %v, want true without readiness probe", desc.Ready)
|
||||
}
|
||||
if desc.ReadyError != nil {
|
||||
t.Fatalf("ReadyError = %v, want nil without readiness probe", desc.ReadyError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightFromManifestReturnsTypedStatusAndRemediation(t *testing.T) {
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case len(args) == 1 && args[0] == "--version":
|
||||
return []byte("2026.1.0\n"), nil
|
||||
case len(args) == 1 && args[0] == "status":
|
||||
return []byte(`{"status":"locked"}`), nil
|
||||
default:
|
||||
return nil, errors.New("unexpected bitwarden invocation")
|
||||
}
|
||||
})
|
||||
|
||||
report, err := PreflightFromManifest(PreflightOptions{
|
||||
ServiceName: "graylog-mcp",
|
||||
Shell: "fish",
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PreflightFromManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
if report.Status != PreflightStatusFail {
|
||||
t.Fatalf("Status = %q, want %q", report.Status, PreflightStatusFail)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(report.Summary), "locked") {
|
||||
t.Fatalf("Summary = %q, want lock hint", report.Summary)
|
||||
}
|
||||
if !strings.Contains(report.Remediation, "set -x BW_SESSION (bw unlock --raw)") {
|
||||
t.Fatalf("Remediation = %q, want fish remediation", report.Remediation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBackendStatusIncludesDeclaredEffectiveAndReadiness(t *testing.T) {
|
||||
line := FormatBackendStatus(RuntimeDescription{
|
||||
ManifestSource: "/opt/graylog-mcp/mcp.toml",
|
||||
DeclaredPolicy: BackendBitwardenCLI,
|
||||
EffectivePolicy: BackendBitwardenCLI,
|
||||
DisplayName: "Bitwarden CLI",
|
||||
Ready: false,
|
||||
ReadyError: ErrBWLocked,
|
||||
})
|
||||
|
||||
for _, needle := range []string{
|
||||
"declared=bitwarden-cli",
|
||||
"effective=bitwarden-cli",
|
||||
"display=Bitwarden CLI",
|
||||
"ready=false",
|
||||
"source=/opt/graylog-mcp/mcp.toml",
|
||||
"error=",
|
||||
} {
|
||||
if !strings.Contains(line, needle) {
|
||||
t.Fatalf("line = %q, want substring %q", line, needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,45 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/99designs/keyring"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("secret not found")
|
||||
var ErrBackendUnavailable = errors.New("secret backend unavailable")
|
||||
var ErrReadOnly = errors.New("secret backend is read-only")
|
||||
var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy")
|
||||
var ErrBWNotLoggedIn = errors.New("bitwarden is not logged in")
|
||||
var ErrBWLocked = errors.New("bitwarden vault is locked or BW_SESSION is missing")
|
||||
var ErrBWUnavailable = errors.New("bitwarden CLI unavailable")
|
||||
|
||||
type BackendPolicy string
|
||||
|
||||
const (
|
||||
BackendAuto BackendPolicy = "auto"
|
||||
BackendKWalletOnly BackendPolicy = "kwallet-only"
|
||||
BackendKeyringAny BackendPolicy = "keyring-any"
|
||||
BackendEnvOnly BackendPolicy = "env-only"
|
||||
BackendBitwardenCLI BackendPolicy = "bitwarden-cli"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
ServiceName string
|
||||
ServiceName string
|
||||
BackendPolicy BackendPolicy
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
DisableBitwardenCache bool
|
||||
Shell string
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
|
|
@ -21,28 +48,89 @@ type Store interface {
|
|||
DeleteSecret(name string) error
|
||||
}
|
||||
|
||||
type BackendUnavailableError struct {
|
||||
Policy BackendPolicy
|
||||
Required string
|
||||
Available []string
|
||||
}
|
||||
|
||||
func (e *BackendUnavailableError) Error() string {
|
||||
if len(e.Available) == 0 {
|
||||
return fmt.Sprintf("secret backend policy %q requires %s, but no compatible backend is available", e.Policy, e.Required)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"secret backend policy %q requires %s, but only [%s] are available",
|
||||
e.Policy,
|
||||
e.Required,
|
||||
strings.Join(e.Available, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
func (e *BackendUnavailableError) Unwrap() error {
|
||||
return ErrBackendUnavailable
|
||||
}
|
||||
|
||||
type keyringStore struct {
|
||||
ring keyring.Keyring
|
||||
serviceName string
|
||||
}
|
||||
|
||||
type envStore struct {
|
||||
lookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
var (
|
||||
openKeyring = keyring.Open
|
||||
availableKeyringPolicy = keyring.AvailableBackends
|
||||
)
|
||||
|
||||
func Open(options Options) (Store, error) {
|
||||
policy, err := normalizeBackendPolicy(options.BackendPolicy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if policy == BackendEnvOnly {
|
||||
return newEnvStore(options), nil
|
||||
}
|
||||
|
||||
serviceName := strings.TrimSpace(options.ServiceName)
|
||||
if serviceName == "" {
|
||||
return nil, errors.New("service name must not be empty")
|
||||
}
|
||||
|
||||
ring, err := keyring.Open(keyring.Config{
|
||||
ServiceName: serviceName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open OS wallet backend %q for service %q: %w", BackendName(), serviceName, err)
|
||||
if policy == BackendBitwardenCLI {
|
||||
return newBitwardenStore(options, policy, serviceName)
|
||||
}
|
||||
|
||||
return &keyringStore{
|
||||
ring: ring,
|
||||
serviceName: serviceName,
|
||||
}, nil
|
||||
available := availableKeyringPolicy()
|
||||
allowed, err := allowedBackends(policy, available)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrBackendUnavailable) && policy == BackendAuto && options.LookupEnv != nil {
|
||||
return newEnvStore(options), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ring, err := openKeyring(keyring.Config{
|
||||
ServiceName: serviceName,
|
||||
AllowedBackends: allowed,
|
||||
KWalletAppID: strings.TrimSpace(options.KWalletAppID),
|
||||
KWalletFolder: strings.TrimSpace(options.KWalletFolder),
|
||||
})
|
||||
if err == nil {
|
||||
return &keyringStore{
|
||||
ring: ring,
|
||||
serviceName: serviceName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if policy == BackendAuto && options.LookupEnv != nil {
|
||||
return newEnvStore(options), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("open secret backend for service %q with policy %q: %w", serviceName, policy, err)
|
||||
}
|
||||
|
||||
func BackendName() string {
|
||||
|
|
@ -58,6 +146,72 @@ func BackendName() string {
|
|||
}
|
||||
}
|
||||
|
||||
func EffectiveBackendPolicy(store Store) BackendPolicy {
|
||||
switch store.(type) {
|
||||
case *bitwardenStore:
|
||||
return BackendBitwardenCLI
|
||||
case *envStore:
|
||||
return BackendEnvOnly
|
||||
case *keyringStore:
|
||||
return BackendKeyringAny
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func SetSecretVerified(store Store, name, label, secret string) error {
|
||||
if store == nil {
|
||||
return errors.New("secret store must not be nil")
|
||||
}
|
||||
|
||||
if err := store.SetSecret(name, label, secret); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
verified, err := store.GetSecret(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify secret %q after write: %w", name, err)
|
||||
}
|
||||
|
||||
if verified != secret {
|
||||
return fmt.Errorf("verify secret %q after write: read-back mismatch", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetJSON[T any](store Store, name, label string, value T) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode structured secret %q as JSON: %w", name, err)
|
||||
}
|
||||
|
||||
return store.SetSecret(name, label, string(data))
|
||||
}
|
||||
|
||||
func GetJSON[T any](store Store, name string) (T, error) {
|
||||
var value T
|
||||
if err := GetJSONInto(store, name, &value); err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func GetJSONInto(store Store, name string, target any) error {
|
||||
secret, err := store.GetSecret(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(secret), target); err != nil {
|
||||
return fmt.Errorf("decode structured secret %q from JSON: %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *keyringStore) SetSecret(name, label, secret string) error {
|
||||
if err := s.ring.Set(keyring.Item{
|
||||
Key: name,
|
||||
|
|
@ -88,3 +242,80 @@ func (s *keyringStore) DeleteSecret(name string) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *envStore) SetSecret(name, label, secret string) error {
|
||||
return fmt.Errorf("save secret %q in environment backend: %w", name, ErrReadOnly)
|
||||
}
|
||||
|
||||
func (s *envStore) GetSecret(name string) (string, error) {
|
||||
value, ok := s.lookupEnv(name)
|
||||
if !ok {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *envStore) DeleteSecret(name string) error {
|
||||
return fmt.Errorf("delete secret %q from environment backend: %w", name, ErrReadOnly)
|
||||
}
|
||||
|
||||
func newEnvStore(options Options) Store {
|
||||
lookupEnv := options.LookupEnv
|
||||
if lookupEnv == nil {
|
||||
lookupEnv = os.LookupEnv
|
||||
}
|
||||
|
||||
return &envStore{lookupEnv: lookupEnv}
|
||||
}
|
||||
|
||||
func allowedBackends(policy BackendPolicy, available []keyring.BackendType) ([]keyring.BackendType, error) {
|
||||
switch policy {
|
||||
case BackendAuto, BackendKeyringAny:
|
||||
if len(available) == 0 {
|
||||
return nil, &BackendUnavailableError{
|
||||
Policy: policy,
|
||||
Required: "any keyring backend",
|
||||
Available: nil,
|
||||
}
|
||||
}
|
||||
return slices.Clone(available), nil
|
||||
case BackendKWalletOnly:
|
||||
if !slices.Contains(available, keyring.KWalletBackend) {
|
||||
return nil, &BackendUnavailableError{
|
||||
Policy: policy,
|
||||
Required: string(keyring.KWalletBackend),
|
||||
Available: backendNames(available),
|
||||
}
|
||||
}
|
||||
return []keyring.BackendType{keyring.KWalletBackend}, nil
|
||||
default:
|
||||
return nil, invalidBackendPolicyError(policy)
|
||||
}
|
||||
}
|
||||
|
||||
func backendNames(backends []keyring.BackendType) []string {
|
||||
names := make([]string, 0, len(backends))
|
||||
for _, backend := range backends {
|
||||
names = append(names, string(backend))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) {
|
||||
trimmed := BackendPolicy(strings.TrimSpace(string(policy)))
|
||||
if trimmed == "" {
|
||||
return BackendAuto, nil
|
||||
}
|
||||
|
||||
switch trimmed {
|
||||
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly, BackendBitwardenCLI:
|
||||
return trimmed, nil
|
||||
default:
|
||||
return "", invalidBackendPolicyError(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
func invalidBackendPolicyError(policy BackendPolicy) error {
|
||||
return fmt.Errorf("%w %q", ErrInvalidBackendPolicy, policy)
|
||||
}
|
||||
|
|
|
|||
335
secretstore/store_test.go
Normal file
335
secretstore/store_test.go
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/99designs/keyring"
|
||||
)
|
||||
|
||||
type stubKeyring struct {
|
||||
items map[string]keyring.Item
|
||||
}
|
||||
|
||||
func (s *stubKeyring) Get(key string) (keyring.Item, error) {
|
||||
item, ok := s.items[key]
|
||||
if !ok {
|
||||
return keyring.Item{}, keyring.ErrKeyNotFound
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *stubKeyring) GetMetadata(key string) (keyring.Metadata, error) {
|
||||
item, err := s.Get(key)
|
||||
if err != nil {
|
||||
return keyring.Metadata{}, err
|
||||
}
|
||||
|
||||
return keyring.Metadata{Item: &item}, nil
|
||||
}
|
||||
|
||||
func (s *stubKeyring) Set(item keyring.Item) error {
|
||||
if s.items == nil {
|
||||
s.items = map[string]keyring.Item{}
|
||||
}
|
||||
|
||||
s.items[item.Key] = item
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubKeyring) Remove(key string) error {
|
||||
delete(s.items, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubKeyring) Keys() ([]string, error) {
|
||||
keys := make([]string, 0, len(s.items))
|
||||
for key := range s.items {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func withKeyringHooks(
|
||||
t *testing.T,
|
||||
available []keyring.BackendType,
|
||||
opener func(cfg keyring.Config) (keyring.Keyring, error),
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
prevAvailable := availableKeyringPolicy
|
||||
prevOpen := openKeyring
|
||||
availableKeyringPolicy = func() []keyring.BackendType {
|
||||
return available
|
||||
}
|
||||
openKeyring = opener
|
||||
|
||||
t.Cleanup(func() {
|
||||
availableKeyringPolicy = prevAvailable
|
||||
openKeyring = prevOpen
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenRejectsInvalidPolicy(t *testing.T) {
|
||||
_, err := Open(Options{
|
||||
ServiceName: "mcp-framework-test",
|
||||
BackendPolicy: BackendPolicy("invalid"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidBackendPolicy) {
|
||||
t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAutoUsesAvailableKeyringBackends(t *testing.T) {
|
||||
var gotAllowed []keyring.BackendType
|
||||
ring := &stubKeyring{}
|
||||
|
||||
withKeyringHooks(t, []keyring.BackendType{
|
||||
keyring.SecretServiceBackend,
|
||||
keyring.KWalletBackend,
|
||||
}, func(cfg keyring.Config) (keyring.Keyring, error) {
|
||||
gotAllowed = append([]keyring.BackendType(nil), cfg.AllowedBackends...)
|
||||
return ring, nil
|
||||
})
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "mcp-framework-test",
|
||||
BackendPolicy: BackendAuto,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := store.SetSecret("token", "API token", "secret-value"); err != nil {
|
||||
t.Fatalf("SetSecret returned error: %v", err)
|
||||
}
|
||||
|
||||
value, err := store.GetSecret("token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if value != "secret-value" {
|
||||
t.Fatalf("GetSecret = %q, want secret-value", value)
|
||||
}
|
||||
|
||||
if len(gotAllowed) != 2 {
|
||||
t.Fatalf("allowed backends = %v, want two entries", gotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAutoFallsBackToEnvironmentWhenNoKeyringBackendIsAvailable(t *testing.T) {
|
||||
withKeyringHooks(t, nil, func(cfg keyring.Config) (keyring.Keyring, error) {
|
||||
t.Fatal("unexpected keyring open call")
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "mcp-framework-test",
|
||||
BackendPolicy: BackendAuto,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
if name == "EMAIL_CREDENTIALS" {
|
||||
return `{"username":"alice"}`, true
|
||||
}
|
||||
return "", false
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
value, err := store.GetSecret("EMAIL_CREDENTIALS")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if value != `{"username":"alice"}` {
|
||||
t.Fatalf("GetSecret = %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenKeyringAnyReturnsExplicitUnavailableError(t *testing.T) {
|
||||
withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) {
|
||||
t.Fatal("unexpected keyring open call")
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
_, err := Open(Options{
|
||||
ServiceName: "mcp-framework-test",
|
||||
BackendPolicy: BackendKWalletOnly,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var backendErr *BackendUnavailableError
|
||||
if !errors.As(err, &backendErr) {
|
||||
t.Fatalf("error = %v, want BackendUnavailableError", err)
|
||||
}
|
||||
if !errors.Is(err, ErrBackendUnavailable) {
|
||||
t.Fatalf("error = %v, want ErrBackendUnavailable", err)
|
||||
}
|
||||
if backendErr.Required != "kwallet" {
|
||||
t.Fatalf("required backend = %q, want kwallet", backendErr.Required)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenEnvOnlyIsReadOnlyAndUsesLookupEnv(t *testing.T) {
|
||||
store, err := Open(Options{
|
||||
BackendPolicy: BackendEnvOnly,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
if name == "API_TOKEN" {
|
||||
return "from-env", true
|
||||
}
|
||||
return "", false
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
value, err := store.GetSecret("API_TOKEN")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if value != "from-env" {
|
||||
t.Fatalf("GetSecret = %q, want from-env", value)
|
||||
}
|
||||
|
||||
err = store.SetSecret("API_TOKEN", "API token", "new-value")
|
||||
if !errors.Is(err, ErrReadOnly) {
|
||||
t.Fatalf("SetSecret error = %v, want ErrReadOnly", err)
|
||||
}
|
||||
|
||||
err = store.DeleteSecret("API_TOKEN")
|
||||
if !errors.Is(err, ErrReadOnly) {
|
||||
t.Fatalf("DeleteSecret error = %v, want ErrReadOnly", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONHelpersRoundTrip(t *testing.T) {
|
||||
ring := &stubKeyring{}
|
||||
withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) {
|
||||
return ring, nil
|
||||
})
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "mcp-framework-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
type credentials struct {
|
||||
Host string `json:"host"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
input := credentials{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: "s3cr3t",
|
||||
}
|
||||
|
||||
if err := SetJSON(store, "imap-credentials", "IMAP credentials", input); err != nil {
|
||||
t.Fatalf("SetJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
output, err := GetJSON[credentials](store, "imap-credentials")
|
||||
if err != nil {
|
||||
t.Fatalf("GetJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
if output != input {
|
||||
t.Fatalf("GetJSON = %#v, want %#v", output, input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveBackendPolicyReportsConcreteBackend(t *testing.T) {
|
||||
t.Run("env-only", func(t *testing.T) {
|
||||
store, err := Open(Options{
|
||||
BackendPolicy: BackendEnvOnly,
|
||||
LookupEnv: func(string) (string, bool) { return "", false },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := EffectiveBackendPolicy(store); got != BackendEnvOnly {
|
||||
t.Fatalf("EffectiveBackendPolicy = %q, want %q", got, BackendEnvOnly)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keyring", func(t *testing.T) {
|
||||
withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) {
|
||||
return &stubKeyring{}, nil
|
||||
})
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "mcp-framework-test",
|
||||
BackendPolicy: BackendAuto,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := EffectiveBackendPolicy(store); got != BackendKeyringAny {
|
||||
t.Fatalf("EffectiveBackendPolicy = %q, want %q", got, BackendKeyringAny)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetSecretVerifiedWritesThenReadsBack(t *testing.T) {
|
||||
ring := &stubKeyring{}
|
||||
withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) {
|
||||
return ring, nil
|
||||
})
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "mcp-framework-test",
|
||||
BackendPolicy: BackendAuto,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := SetSecretVerified(store, "token", "API token", "secret-value"); err != nil {
|
||||
t.Fatalf("SetSecretVerified returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSecretVerifiedFailsOnReadBackMismatch(t *testing.T) {
|
||||
store := &mismatchSecretStore{}
|
||||
|
||||
err := SetSecretVerified(store, "token", "API token", "secret-value")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("error = %v, want wrapped ErrNotFound", err)
|
||||
}
|
||||
if store.setCalls != 1 {
|
||||
t.Fatalf("setCalls = %d, want 1", store.setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type mismatchSecretStore struct {
|
||||
setCalls int
|
||||
}
|
||||
|
||||
func (s *mismatchSecretStore) SetSecret(name, label, secret string) error {
|
||||
s.setCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mismatchSecretStore) GetSecret(name string) (string, error) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
func (s *mismatchSecretStore) DeleteSecret(name string) error {
|
||||
return nil
|
||||
}
|
||||
847
update/update.go
847
update/update.go
|
|
@ -1,7 +1,12 @@
|
|||
package update
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -9,31 +14,64 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultAssetNameTemplate = "{binary}-{os}-{arch}{ext}"
|
||||
const defaultMaxDownloadBytes int64 = 200 * 1024 * 1024
|
||||
const downloadedArtifactSniffBytes = 4096
|
||||
|
||||
type Options struct {
|
||||
Client *http.Client
|
||||
CurrentVersion string
|
||||
ExecutablePath string
|
||||
LatestReleaseURL string
|
||||
Stdout io.Writer
|
||||
BinaryName string
|
||||
ReleaseSource ReleaseSource
|
||||
GOOS string
|
||||
GOARCH string
|
||||
Client *http.Client
|
||||
CurrentVersion string
|
||||
ExecutablePath string
|
||||
LatestReleaseURL string
|
||||
Stdout io.Writer
|
||||
BinaryName string
|
||||
AssetNameTemplate string
|
||||
ReleaseSource ReleaseSource
|
||||
GOOS string
|
||||
GOARCH string
|
||||
MaxDownloadBytes int64
|
||||
ValidateDownloaded ValidateDownloadedFunc
|
||||
ReplaceExecutable ReplaceExecutableFunc
|
||||
}
|
||||
|
||||
type ReplaceExecutableFunc func(downloadPath, targetPath string) error
|
||||
|
||||
type ValidateDownloadedFunc func(context.Context, ValidationInput) error
|
||||
|
||||
type ValidationInput struct {
|
||||
DownloadPath string
|
||||
TargetPath string
|
||||
AssetName string
|
||||
ReleaseTag string
|
||||
ReleaseURL string
|
||||
Source ReleaseSource
|
||||
}
|
||||
|
||||
type ReleaseSource struct {
|
||||
Name string
|
||||
BaseURL string
|
||||
LatestReleaseURL string
|
||||
Token string
|
||||
TokenHeader string
|
||||
TokenEnvNames []string
|
||||
Name string
|
||||
Driver string
|
||||
Repository string
|
||||
BaseURL string
|
||||
LatestReleaseURL string
|
||||
AssetNameTemplate string
|
||||
ChecksumAssetName string
|
||||
ChecksumRequired bool
|
||||
SignatureAssetName string
|
||||
SignatureRequired bool
|
||||
SignaturePublicKey string
|
||||
SignaturePublicKeyEnvNames []string
|
||||
Token string
|
||||
TokenHeader string
|
||||
TokenPrefix string
|
||||
TokenEnvNames []string
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
|
|
@ -53,6 +91,33 @@ type ReleaseLink struct {
|
|||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type releasePayload struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets json.RawMessage `json:"assets"`
|
||||
}
|
||||
|
||||
type releaseAssetsPayload struct {
|
||||
Links []releaseLinkPayload `json:"links"`
|
||||
}
|
||||
|
||||
type releaseLinkPayload struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
DirectAssetURL string `json:"direct_asset_url"`
|
||||
}
|
||||
|
||||
func (r *Release) UnmarshalJSON(data []byte) error {
|
||||
var payload releasePayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.TagName = strings.TrimSpace(payload.TagName)
|
||||
r.Assets.Links = parseReleaseLinks(payload.Assets)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, opts Options) error {
|
||||
if opts.Stdout == nil {
|
||||
opts.Stdout = io.Discard
|
||||
|
|
@ -69,16 +134,16 @@ func Run(ctx context.Context, opts Options) error {
|
|||
if strings.TrimSpace(opts.GOARCH) == "" {
|
||||
opts.GOARCH = runtime.GOARCH
|
||||
}
|
||||
if opts.MaxDownloadBytes <= 0 {
|
||||
opts.MaxDownloadBytes = defaultMaxDownloadBytes
|
||||
}
|
||||
|
||||
source := normalizeSource(opts.ReleaseSource)
|
||||
auth := ResolveAuth(source.Token, source)
|
||||
|
||||
releaseURL := opts.LatestReleaseURL
|
||||
if strings.TrimSpace(releaseURL) == "" {
|
||||
releaseURL = strings.TrimSpace(source.LatestReleaseURL)
|
||||
}
|
||||
if releaseURL == "" {
|
||||
return errors.New("latest release URL must not be empty")
|
||||
releaseURL, err := ResolveLatestReleaseURL(opts.LatestReleaseURL, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetPath, err := ResolveUpdateTarget(opts.ExecutablePath)
|
||||
|
|
@ -86,7 +151,12 @@ func Run(ctx context.Context, opts Options) error {
|
|||
return err
|
||||
}
|
||||
|
||||
assetName, err := AssetName(opts.BinaryName, opts.GOOS, opts.GOARCH)
|
||||
assetTemplate := strings.TrimSpace(opts.AssetNameTemplate)
|
||||
if assetTemplate == "" {
|
||||
assetTemplate = source.AssetNameTemplate
|
||||
}
|
||||
|
||||
assetName, err := AssetNameWithTemplate(opts.BinaryName, opts.GOOS, opts.GOARCH, assetTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -105,13 +175,41 @@ func Run(ctx context.Context, opts Options) error {
|
|||
return err
|
||||
}
|
||||
|
||||
downloadPath, err := DownloadReleaseAsset(ctx, opts.Client, assetURL, targetPath, auth, source)
|
||||
downloadPath, err := DownloadReleaseAsset(ctx, opts.Client, assetURL, targetPath, auth, source, opts.MaxDownloadBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(downloadPath)
|
||||
|
||||
if err := ReplaceExecutable(downloadPath, targetPath); err != nil {
|
||||
if err := validateDownloadedArtifact(downloadPath, assetName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := VerifyReleaseAssetChecksum(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := VerifyReleaseAssetSignature(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.ValidateDownloaded != nil {
|
||||
if err := opts.ValidateDownloaded(ctx, ValidationInput{
|
||||
DownloadPath: downloadPath,
|
||||
TargetPath: targetPath,
|
||||
AssetName: assetName,
|
||||
ReleaseTag: release.TagName,
|
||||
ReleaseURL: releaseURL,
|
||||
Source: source,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("validate downloaded artifact: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
replaceExecutable := opts.ReplaceExecutable
|
||||
if replaceExecutable == nil {
|
||||
replaceExecutable = ReplaceExecutable
|
||||
}
|
||||
if err := replaceExecutable(downloadPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -119,16 +217,101 @@ func Run(ctx context.Context, opts Options) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateDownloadedArtifact(path, assetName string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate downloaded artifact %q: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
head := make([]byte, downloadedArtifactSniffBytes)
|
||||
n, readErr := file.Read(head)
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
return fmt.Errorf("validate downloaded artifact %q: %w", path, readErr)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("downloaded artifact %q is empty", assetName)
|
||||
}
|
||||
|
||||
if looksLikeHTMLDocument(head[:n]) {
|
||||
return fmt.Errorf(
|
||||
"downloaded artifact %q looks like an HTML page (possible auth/forbidden response)",
|
||||
assetName,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func looksLikeHTMLDocument(content []byte) bool {
|
||||
if len(content) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
detectedContentType := strings.ToLower(http.DetectContentType(content))
|
||||
if strings.HasPrefix(detectedContentType, "text/html") {
|
||||
return true
|
||||
}
|
||||
|
||||
trimmed := bytes.TrimSpace(content)
|
||||
trimmed = bytes.TrimPrefix(trimmed, []byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM
|
||||
if len(trimmed) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
lower := strings.ToLower(string(trimmed))
|
||||
return strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html")
|
||||
}
|
||||
|
||||
func ResolveLatestReleaseURL(explicit string, source ReleaseSource) (string, error) {
|
||||
if releaseURL := strings.TrimSpace(explicit); releaseURL != "" {
|
||||
return releaseURL, nil
|
||||
}
|
||||
|
||||
source = normalizeSource(source)
|
||||
if source.LatestReleaseURL != "" {
|
||||
return source.LatestReleaseURL, nil
|
||||
}
|
||||
|
||||
if source.Driver == "" {
|
||||
return "", errors.New("latest release URL must not be empty (set latest_release_url or configure driver+repository)")
|
||||
}
|
||||
if source.Repository == "" {
|
||||
return "", fmt.Errorf("release source %q requires repository when driver is set", source.Driver)
|
||||
}
|
||||
|
||||
switch source.Driver {
|
||||
case "gitea":
|
||||
if source.BaseURL == "" {
|
||||
return "", errors.New("release source gitea requires base_url")
|
||||
}
|
||||
return fmt.Sprintf("%s/api/v1/repos/%s/releases/latest", source.BaseURL, source.Repository), nil
|
||||
case "gitlab":
|
||||
projectPath := url.PathEscape(source.Repository)
|
||||
return fmt.Sprintf("%s/api/v4/projects/%s/releases/permalink/latest", source.BaseURL, projectPath), nil
|
||||
case "github":
|
||||
return fmt.Sprintf("%s/repos/%s/releases/latest", source.BaseURL, source.Repository), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported release driver %q (expected gitea, gitlab or github)", source.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveAuth(explicitToken string, source ReleaseSource) Auth {
|
||||
source = normalizeSource(source)
|
||||
|
||||
if token := strings.TrimSpace(explicitToken); token != "" {
|
||||
return Auth{Header: source.TokenHeader, Token: token}
|
||||
return Auth{
|
||||
Header: source.TokenHeader,
|
||||
Token: withTokenPrefix(token, source.TokenPrefix),
|
||||
}
|
||||
}
|
||||
|
||||
for _, envName := range source.TokenEnvNames {
|
||||
if token := strings.TrimSpace(os.Getenv(envName)); token != "" {
|
||||
return Auth{Header: source.TokenHeader, Token: token}
|
||||
return Auth{
|
||||
Header: source.TokenHeader,
|
||||
Token: withTokenPrefix(token, source.TokenPrefix),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -153,23 +336,46 @@ func ResolveUpdateTarget(explicitPath string) (string, error) {
|
|||
}
|
||||
|
||||
func AssetName(binaryName, goos, goarch string) (string, error) {
|
||||
return AssetNameWithTemplate(binaryName, goos, goarch, defaultAssetNameTemplate)
|
||||
}
|
||||
|
||||
func AssetNameWithTemplate(binaryName, goos, goarch, template string) (string, error) {
|
||||
name := strings.TrimSpace(binaryName)
|
||||
if name == "" {
|
||||
return "", errors.New("binary name must not be empty")
|
||||
}
|
||||
|
||||
switch {
|
||||
case goos == "darwin" && goarch == "amd64":
|
||||
return name + "-darwin-amd64", nil
|
||||
case goos == "darwin" && goarch == "arm64":
|
||||
return name + "-darwin-arm64", nil
|
||||
case goos == "linux" && goarch == "amd64":
|
||||
return name + "-linux-amd64", nil
|
||||
case goos == "windows" && goarch == "amd64":
|
||||
return name + "-windows-amd64.exe", nil
|
||||
default:
|
||||
return "", fmt.Errorf("no release artifact for %s/%s", goos, goarch)
|
||||
osName := strings.ToLower(strings.TrimSpace(goos))
|
||||
archName := strings.ToLower(strings.TrimSpace(goarch))
|
||||
if osName == "" || archName == "" {
|
||||
return "", errors.New("goos and goarch must not be empty")
|
||||
}
|
||||
|
||||
assetTemplate := strings.TrimSpace(template)
|
||||
if assetTemplate == "" {
|
||||
assetTemplate = defaultAssetNameTemplate
|
||||
}
|
||||
|
||||
ext := ""
|
||||
if osName == "windows" {
|
||||
ext = ".exe"
|
||||
}
|
||||
|
||||
replaced := strings.NewReplacer(
|
||||
"{binary}", name,
|
||||
"{os}", osName,
|
||||
"{arch}", archName,
|
||||
"{ext}", ext,
|
||||
).Replace(assetTemplate)
|
||||
replaced = strings.TrimSpace(replaced)
|
||||
if replaced == "" {
|
||||
return "", errors.New("asset name template resolved to an empty value")
|
||||
}
|
||||
if strings.ContainsRune(replaced, '/') || strings.ContainsRune(replaced, '\\') {
|
||||
return "", fmt.Errorf("asset name %q must not contain path separators", replaced)
|
||||
}
|
||||
|
||||
return replaced, nil
|
||||
}
|
||||
|
||||
func FetchLatestRelease(ctx context.Context, client *http.Client, releaseURL string, auth Auth, source ReleaseSource) (Release, error) {
|
||||
|
|
@ -232,10 +438,40 @@ func (r Release) AssetURL(assetName, releaseURL string) (string, error) {
|
|||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("latest release does not contain asset %q", assetName)
|
||||
availableAssets := make([]string, 0, len(r.Assets.Links))
|
||||
for _, link := range r.Assets.Links {
|
||||
if name := strings.TrimSpace(link.Name); name != "" {
|
||||
availableAssets = append(availableAssets, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(availableAssets)
|
||||
if len(availableAssets) == 0 {
|
||||
return "", fmt.Errorf("latest release does not contain asset %q", assetName)
|
||||
}
|
||||
|
||||
preview := availableAssets
|
||||
if len(preview) > 8 {
|
||||
preview = preview[:8]
|
||||
}
|
||||
return "", fmt.Errorf(
|
||||
"latest release does not contain asset %q (available: %s)",
|
||||
assetName,
|
||||
strings.Join(preview, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, targetPath string, auth Auth, source ReleaseSource) (string, error) {
|
||||
func DownloadReleaseAsset(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
assetURL, targetPath string,
|
||||
auth Auth,
|
||||
source ReleaseSource,
|
||||
maxDownloadBytes int64,
|
||||
) (string, error) {
|
||||
if maxDownloadBytes <= 0 {
|
||||
maxDownloadBytes = defaultMaxDownloadBytes
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build artifact download request: %w", err)
|
||||
|
|
@ -260,6 +496,13 @@ func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, ta
|
|||
strings.TrimSpace(string(body)),
|
||||
)
|
||||
}
|
||||
if resp.ContentLength > 0 && resp.ContentLength > maxDownloadBytes {
|
||||
return "", fmt.Errorf(
|
||||
"download release artifact: content length %d exceeds limit %d bytes",
|
||||
resp.ContentLength,
|
||||
maxDownloadBytes,
|
||||
)
|
||||
}
|
||||
|
||||
existingInfo, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
|
|
@ -278,9 +521,14 @@ func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, ta
|
|||
return "", copyErr
|
||||
}
|
||||
|
||||
if _, err := io.Copy(tempFile, resp.Body); err != nil {
|
||||
limited := &io.LimitedReader{R: resp.Body, N: maxDownloadBytes + 1}
|
||||
written, err := io.Copy(tempFile, limited)
|
||||
if err != nil {
|
||||
return cleanup(fmt.Errorf("write downloaded artifact: %w", err))
|
||||
}
|
||||
if written > maxDownloadBytes {
|
||||
return cleanup(fmt.Errorf("write downloaded artifact: size exceeds limit %d bytes", maxDownloadBytes))
|
||||
}
|
||||
if err := tempFile.Chmod(existingInfo.Mode().Perm()); err != nil {
|
||||
return cleanup(fmt.Errorf("set executable mode on downloaded artifact: %w", err))
|
||||
}
|
||||
|
|
@ -292,9 +540,121 @@ func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, ta
|
|||
return tempPath, nil
|
||||
}
|
||||
|
||||
func VerifyReleaseAssetChecksum(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
release Release,
|
||||
releaseURL string,
|
||||
assetName string,
|
||||
artifactPath string,
|
||||
auth Auth,
|
||||
source ReleaseSource,
|
||||
) error {
|
||||
source = normalizeSource(source)
|
||||
|
||||
checksumAssetName := resolveChecksumAssetName(assetName, source.ChecksumAssetName)
|
||||
checksumURL, err := release.AssetURL(checksumAssetName, releaseURL)
|
||||
if err != nil {
|
||||
if source.ChecksumRequired {
|
||||
return fmt.Errorf("checksum verification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
checksumBody, err := downloadAssetBytes(ctx, client, checksumURL, auth, source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checksum verification: %w", err)
|
||||
}
|
||||
|
||||
expected, err := parseChecksum(string(checksumBody), assetName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checksum verification: %w", err)
|
||||
}
|
||||
|
||||
actual, err := fileSHA256(artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checksum verification: %w", err)
|
||||
}
|
||||
|
||||
if !strings.EqualFold(expected, actual) {
|
||||
return fmt.Errorf(
|
||||
"checksum mismatch for asset %q: expected %s, got %s",
|
||||
assetName,
|
||||
expected,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func VerifyReleaseAssetSignature(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
release Release,
|
||||
releaseURL string,
|
||||
assetName string,
|
||||
artifactPath string,
|
||||
auth Auth,
|
||||
source ReleaseSource,
|
||||
) error {
|
||||
source = normalizeSource(source)
|
||||
|
||||
publicKey, hasPublicKey, err := resolveEd25519PublicKey(source.SignaturePublicKey, source.SignaturePublicKeyEnvNames)
|
||||
if err != nil {
|
||||
return fmt.Errorf("signature verification: %w", err)
|
||||
}
|
||||
if !hasPublicKey {
|
||||
if source.SignatureRequired {
|
||||
if len(source.SignaturePublicKeyEnvNames) > 0 {
|
||||
return fmt.Errorf(
|
||||
"signature verification: no Ed25519 public key configured (set %s)",
|
||||
strings.Join(source.SignaturePublicKeyEnvNames, " or "),
|
||||
)
|
||||
}
|
||||
return errors.New("signature verification: no Ed25519 public key configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
signatureAssetName := resolveSignatureAssetName(assetName, source.SignatureAssetName)
|
||||
signatureURL, err := release.AssetURL(signatureAssetName, releaseURL)
|
||||
if err != nil {
|
||||
if source.SignatureRequired {
|
||||
return fmt.Errorf("signature verification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
signatureBody, err := downloadAssetBytes(ctx, client, signatureURL, auth, source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("signature verification: %w", err)
|
||||
}
|
||||
|
||||
signature, err := parseEd25519Signature(string(signatureBody), assetName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("signature verification: %w", err)
|
||||
}
|
||||
|
||||
digestHex, err := fileSHA256(artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("signature verification: %w", err)
|
||||
}
|
||||
digest, err := hex.DecodeString(digestHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("signature verification: decode local artifact digest: %w", err)
|
||||
}
|
||||
|
||||
if !ed25519.Verify(publicKey, digest, signature) {
|
||||
return fmt.Errorf("signature mismatch for asset %q", assetName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReplaceExecutable(downloadPath, targetPath string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return errors.New("self-update is not supported on windows")
|
||||
return errors.New("self-update is not supported on windows without a custom ReplaceExecutable hook")
|
||||
}
|
||||
if err := os.Rename(downloadPath, targetPath); err != nil {
|
||||
return fmt.Errorf("replace executable %q: %w", targetPath, err)
|
||||
|
|
@ -304,9 +664,79 @@ func ReplaceExecutable(downloadPath, targetPath string) error {
|
|||
|
||||
func normalizeSource(source ReleaseSource) ReleaseSource {
|
||||
source.Name = strings.TrimSpace(source.Name)
|
||||
source.Driver = strings.ToLower(strings.TrimSpace(source.Driver))
|
||||
source.Repository = strings.Trim(strings.TrimSpace(source.Repository), "/")
|
||||
source.BaseURL = strings.TrimRight(strings.TrimSpace(source.BaseURL), "/")
|
||||
source.LatestReleaseURL = strings.TrimSpace(source.LatestReleaseURL)
|
||||
source.AssetNameTemplate = strings.TrimSpace(source.AssetNameTemplate)
|
||||
source.ChecksumAssetName = strings.TrimSpace(source.ChecksumAssetName)
|
||||
source.SignatureAssetName = strings.TrimSpace(source.SignatureAssetName)
|
||||
source.SignaturePublicKey = strings.TrimSpace(source.SignaturePublicKey)
|
||||
source.Token = strings.TrimSpace(source.Token)
|
||||
source.TokenHeader = strings.TrimSpace(source.TokenHeader)
|
||||
source.TokenPrefix = strings.TrimSpace(source.TokenPrefix)
|
||||
|
||||
envNames := source.TokenEnvNames[:0]
|
||||
for _, envName := range source.TokenEnvNames {
|
||||
if trimmed := strings.TrimSpace(envName); trimmed != "" {
|
||||
envNames = append(envNames, trimmed)
|
||||
}
|
||||
}
|
||||
source.TokenEnvNames = envNames
|
||||
|
||||
publicKeyEnvNames := source.SignaturePublicKeyEnvNames[:0]
|
||||
for _, envName := range source.SignaturePublicKeyEnvNames {
|
||||
if trimmed := strings.TrimSpace(envName); trimmed != "" {
|
||||
publicKeyEnvNames = append(publicKeyEnvNames, trimmed)
|
||||
}
|
||||
}
|
||||
source.SignaturePublicKeyEnvNames = publicKeyEnvNames
|
||||
|
||||
switch source.Driver {
|
||||
case "gitea":
|
||||
if source.Name == "" {
|
||||
source.Name = "Gitea releases"
|
||||
}
|
||||
if source.TokenHeader == "" {
|
||||
source.TokenHeader = "Authorization"
|
||||
}
|
||||
if source.TokenPrefix == "" {
|
||||
source.TokenPrefix = "token "
|
||||
}
|
||||
if len(source.TokenEnvNames) == 0 {
|
||||
source.TokenEnvNames = []string{"GITEA_TOKEN"}
|
||||
}
|
||||
case "gitlab":
|
||||
if source.Name == "" {
|
||||
source.Name = "GitLab releases"
|
||||
}
|
||||
if source.BaseURL == "" {
|
||||
source.BaseURL = "https://gitlab.com"
|
||||
}
|
||||
if source.TokenHeader == "" {
|
||||
source.TokenHeader = "PRIVATE-TOKEN"
|
||||
}
|
||||
if len(source.TokenEnvNames) == 0 {
|
||||
source.TokenEnvNames = []string{"GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"}
|
||||
}
|
||||
case "github":
|
||||
if source.Name == "" {
|
||||
source.Name = "GitHub releases"
|
||||
}
|
||||
if source.BaseURL == "" {
|
||||
source.BaseURL = "https://api.github.com"
|
||||
}
|
||||
if source.TokenHeader == "" {
|
||||
source.TokenHeader = "Authorization"
|
||||
}
|
||||
if source.TokenPrefix == "" {
|
||||
source.TokenPrefix = "Bearer "
|
||||
}
|
||||
if len(source.TokenEnvNames) == 0 {
|
||||
source.TokenEnvNames = []string{"GITHUB_TOKEN"}
|
||||
}
|
||||
}
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
|
|
@ -375,3 +805,338 @@ func (a Auth) maybeHint(statusCode int, body []byte, source ReleaseSource) error
|
|||
source.TokenEnvNames[1],
|
||||
)
|
||||
}
|
||||
|
||||
func parseReleaseLinks(raw json.RawMessage) []ReleaseLink {
|
||||
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parseLinks := func(payload []releaseLinkPayload) []ReleaseLink {
|
||||
links := make([]ReleaseLink, 0, len(payload))
|
||||
for _, item := range payload {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
assetURL := firstNonEmpty(item.DirectAssetURL, item.BrowserDownloadURL, item.URL)
|
||||
if name == "" || strings.TrimSpace(assetURL) == "" {
|
||||
continue
|
||||
}
|
||||
links = append(links, ReleaseLink{
|
||||
Name: name,
|
||||
URL: strings.TrimSpace(assetURL),
|
||||
})
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
var asObject releaseAssetsPayload
|
||||
if err := json.Unmarshal(raw, &asObject); err == nil && len(asObject.Links) > 0 {
|
||||
return parseLinks(asObject.Links)
|
||||
}
|
||||
|
||||
var asArray []releaseLinkPayload
|
||||
if err := json.Unmarshal(raw, &asArray); err == nil && len(asArray) > 0 {
|
||||
return parseLinks(asArray)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func withTokenPrefix(token, prefix string) string {
|
||||
trimmedToken := strings.TrimSpace(token)
|
||||
if trimmedToken == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
trimmedPrefix := strings.TrimSpace(prefix)
|
||||
if trimmedPrefix == "" {
|
||||
return trimmedToken
|
||||
}
|
||||
|
||||
lowerToken := strings.ToLower(trimmedToken)
|
||||
lowerPrefix := strings.ToLower(trimmedPrefix)
|
||||
if strings.HasPrefix(lowerToken, lowerPrefix) {
|
||||
return trimmedToken
|
||||
}
|
||||
|
||||
return trimmedPrefix + " " + trimmedToken
|
||||
}
|
||||
|
||||
func resolveChecksumAssetName(assetName, configured string) string {
|
||||
value := strings.TrimSpace(configured)
|
||||
if value == "" {
|
||||
return assetName + ".sha256"
|
||||
}
|
||||
return strings.ReplaceAll(value, "{asset}", assetName)
|
||||
}
|
||||
|
||||
func resolveSignatureAssetName(assetName, configured string) string {
|
||||
value := strings.TrimSpace(configured)
|
||||
if value == "" {
|
||||
return assetName + ".sig"
|
||||
}
|
||||
return strings.ReplaceAll(value, "{asset}", assetName)
|
||||
}
|
||||
|
||||
func downloadAssetBytes(ctx context.Context, client *http.Client, assetURL string, auth Auth, source ReleaseSource) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build checksum download request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "mcp updater")
|
||||
auth.apply(req)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download checksum asset: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if hint := auth.maybeHint(resp.StatusCode, body, source); hint != nil {
|
||||
return nil, fmt.Errorf("download checksum asset: %w", hint)
|
||||
}
|
||||
return nil, fmt.Errorf(
|
||||
"download checksum asset: unexpected status %d: %s",
|
||||
resp.StatusCode,
|
||||
strings.TrimSpace(string(body)),
|
||||
)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read checksum asset: %w", err)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func parseChecksum(content, assetName string) (string, error) {
|
||||
lines := strings.Split(content, "\n")
|
||||
fallbackSingle := ""
|
||||
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(strings.ToUpper(line), "SHA256 (") {
|
||||
openIndex := strings.Index(line, "(")
|
||||
closeIndex := strings.LastIndex(line, ")")
|
||||
equalIndex := strings.LastIndex(line, "=")
|
||||
if openIndex >= 0 && closeIndex > openIndex && equalIndex > closeIndex {
|
||||
name := strings.TrimSpace(line[openIndex+1 : closeIndex])
|
||||
hash := strings.TrimSpace(line[equalIndex+1:])
|
||||
if isSHA256Hex(hash) && matchesAssetName(name, assetName) {
|
||||
return strings.ToLower(hash), nil
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 && isSHA256Hex(fields[0]) {
|
||||
if len(fields) == 1 {
|
||||
if fallbackSingle == "" {
|
||||
fallbackSingle = strings.ToLower(fields[0])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(strings.TrimPrefix(fields[1], "*"))
|
||||
if matchesAssetName(name, assetName) {
|
||||
return strings.ToLower(fields[0]), nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
colonIndex := strings.Index(line, ":")
|
||||
if colonIndex > 0 && colonIndex < len(line)-1 {
|
||||
left := strings.TrimSpace(line[:colonIndex])
|
||||
right := strings.TrimSpace(line[colonIndex+1:])
|
||||
switch {
|
||||
case isSHA256Hex(left) && matchesAssetName(right, assetName):
|
||||
return strings.ToLower(left), nil
|
||||
case isSHA256Hex(right) && matchesAssetName(left, assetName):
|
||||
return strings.ToLower(right), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fallbackSingle != "" {
|
||||
return fallbackSingle, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("checksum file does not contain a sha256 for asset %q", assetName)
|
||||
}
|
||||
|
||||
func parseEd25519Signature(content, assetName string) ([]byte, error) {
|
||||
lines := strings.Split(content, "\n")
|
||||
var fallbackSingle []byte
|
||||
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 {
|
||||
if signature, ok := parseEd25519SignatureToken(fields[0]); ok {
|
||||
if len(fields) == 1 {
|
||||
if fallbackSingle == nil {
|
||||
fallbackSingle = signature
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(strings.TrimPrefix(fields[1], "*"))
|
||||
if matchesAssetName(name, assetName) {
|
||||
return signature, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
colonIndex := strings.Index(line, ":")
|
||||
if colonIndex > 0 && colonIndex < len(line)-1 {
|
||||
left := strings.TrimSpace(line[:colonIndex])
|
||||
right := strings.TrimSpace(line[colonIndex+1:])
|
||||
|
||||
if signature, ok := parseEd25519SignatureToken(left); ok && matchesAssetName(right, assetName) {
|
||||
return signature, nil
|
||||
}
|
||||
if signature, ok := parseEd25519SignatureToken(right); ok && matchesAssetName(left, assetName) {
|
||||
return signature, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fallbackSingle != nil {
|
||||
return fallbackSingle, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("signature file does not contain a valid Ed25519 signature for asset %q", assetName)
|
||||
}
|
||||
|
||||
func parseEd25519SignatureToken(value string) ([]byte, bool) {
|
||||
decoded, err := decodeBinaryValue(value, ed25519.SignatureSize)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return decoded, true
|
||||
}
|
||||
|
||||
func resolveEd25519PublicKey(explicit string, envNames []string) (ed25519.PublicKey, bool, error) {
|
||||
key := strings.TrimSpace(explicit)
|
||||
if key != "" {
|
||||
publicKey, err := parseEd25519PublicKey(key)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse ed25519 public key: %w", err)
|
||||
}
|
||||
return publicKey, true, nil
|
||||
}
|
||||
|
||||
for _, envName := range envNames {
|
||||
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
|
||||
publicKey, err := parseEd25519PublicKey(value)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse ed25519 public key from %s: %w", envName, err)
|
||||
}
|
||||
return publicKey, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func parseEd25519PublicKey(value string) (ed25519.PublicKey, error) {
|
||||
decoded, err := decodeBinaryValue(value, ed25519.PublicKeySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ed25519.PublicKey(decoded), nil
|
||||
}
|
||||
|
||||
func decodeBinaryValue(value string, expectedLength int) ([]byte, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("value must not be empty")
|
||||
}
|
||||
|
||||
decoders := []func(string) ([]byte, error){
|
||||
hex.DecodeString,
|
||||
base64.StdEncoding.DecodeString,
|
||||
base64.RawStdEncoding.DecodeString,
|
||||
base64.URLEncoding.DecodeString,
|
||||
base64.RawURLEncoding.DecodeString,
|
||||
}
|
||||
|
||||
lengthMismatch := false
|
||||
for _, decode := range decoders {
|
||||
decoded, err := decode(trimmed)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(decoded) == expectedLength {
|
||||
return decoded, nil
|
||||
}
|
||||
lengthMismatch = true
|
||||
}
|
||||
|
||||
if lengthMismatch {
|
||||
return nil, fmt.Errorf("decoded value has invalid length (expected %d bytes)", expectedLength)
|
||||
}
|
||||
return nil, errors.New("value must be hex or base64 encoded")
|
||||
}
|
||||
|
||||
func matchesAssetName(candidate, assetName string) bool {
|
||||
name := strings.TrimSpace(strings.TrimPrefix(candidate, "*"))
|
||||
name = strings.TrimPrefix(name, "./")
|
||||
if name == assetName {
|
||||
return true
|
||||
}
|
||||
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
if path.Base(name) == assetName {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSHA256Hex(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
case r >= 'a' && r <= 'f':
|
||||
case r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open downloaded artifact: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", fmt.Errorf("hash downloaded artifact: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue