From b3700476506bd6f17a28f9b85376dba5ff6bb46e Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Mon, 13 Apr 2026 19:49:00 +0200 Subject: [PATCH 01/58] docs: document email-mcp validation boundaries --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index adff521..8fd2b55 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,31 @@ Le flux typique côté application est : 4. Charger `mcp.toml` avec `manifest`. 5. Exécuter l'auto-update avec `update` si nécessaire. +## Validation Avec `email-mcp` + +La migration de [`email-mcp`](../email-mcp) sert de cas réel de validation du +framework. + +Ce qui est aujourd'hui mutualisé et utilisé tel quel par `email-mcp` : + +- la résolution du profil actif via `cli` +- le stockage JSON versionné de la config via `config` +- l'accès au wallet natif et aux secrets via `secretstore` +- le chargement de `mcp.toml` via `manifest` +- l'exécution du flux d'auto-update via `update` + +Ce qui reste volontairement spécifique à l'application cliente : + +- l'arbre de commandes et les alias métier (`config`, `setup`, `mcp`, `update`) +- les prompts interactifs et la validation métier des champs saisis +- le découpage entre données de config et secrets, ainsi que le nommage des secrets +- l'assemblage runtime entre config, secrets, services métier et serveur MCP +- le mapping final des erreurs techniques vers des messages utilisateur adaptés + +Ces limites sont des choix explicites : le framework fournit des briques +réutilisables, mais ne force pas une couche de bootstrap unique ni un contrat +applicatif trop spécifique à un premier consommateur. + ## Manifeste `mcp.toml` Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire @@ -323,5 +348,5 @@ func run(ctx context.Context, flagProfile string) error { ## Limites Actuelles - le manifeste gère uniquement la section `[update]` -- le framework ne fournit pas encore d'interface unique de bootstrap +- le framework ne fournit pas d'interface unique de bootstrap applicatif - l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes -- 2.45.2 From df4ad36700cdf98d23008071f6e73364b4d6d6ad Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Mon, 13 Apr 2026 19:57:47 +0200 Subject: [PATCH 02/58] docs: keep framework readme generic --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8fd2b55..abf66b0 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,9 @@ Le flux typique côté application est : 4. Charger `mcp.toml` avec `manifest`. 5. Exécuter l'auto-update avec `update` si nécessaire. -## Validation Avec `email-mcp` +## Frontières Du Framework -La migration de [`email-mcp`](../email-mcp) sert de cas réel de validation du -framework. - -Ce qui est aujourd'hui mutualisé et utilisé tel quel par `email-mcp` : +Ce qui est mutualisé par le framework : - la résolution du profil actif via `cli` - le stockage JSON versionné de la config via `config` @@ -58,7 +55,7 @@ Ce qui reste volontairement spécifique à l'application cliente : Ces limites sont des choix explicites : le framework fournit des briques réutilisables, mais ne force pas une couche de bootstrap unique ni un contrat -applicatif trop spécifique à un premier consommateur. +applicatif trop spécifique à une application donnée. ## Manifeste `mcp.toml` -- 2.45.2 From e9cfad5469770b37a890f4928d5132a6e756abdc Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 13 Apr 2026 20:53:36 +0200 Subject: [PATCH 03/58] feat(cli): add reusable doctor checks --- README.md | 50 ++++++- cli/doctor.go | 336 +++++++++++++++++++++++++++++++++++++++++++++ cli/doctor_test.go | 173 +++++++++++++++++++++++ 3 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 cli/doctor.go create mode 100644 cli/doctor_test.go diff --git a/README.md b/README.md index adff521..e8bcc12 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ go get gitea.lclr.dev/AI/mcp-framework ## Packages -- `cli` : helpers pour résoudre un profil, valider une URL et demander des valeurs à l'utilisateur. +- `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 et conversion vers `update.ReleaseSource`. - `secretstore` : lecture/écriture de secrets dans le wallet natif. @@ -34,6 +34,7 @@ Le flux typique côté application est : 3. Lire les secrets avec `secretstore`. 4. Charger `mcp.toml` avec `manifest`. 5. Exécuter l'auto-update avec `update` si nécessaire. +6. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. ## Manifeste `mcp.toml` @@ -235,6 +236,53 @@ if err != nil { } ``` +Le package fournit aussi un socle réutilisable pour une commande `doctor`. +L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), +mais peut réutiliser les checks communs et ajouter ses propres hooks : + +```go +report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ + ConfigCheck: cli.NewConfigCheck(store), + SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) { + return secretstore.Open(secretstore.Options{ + ServiceName: "my-mcp", + }) + }), + RequiredSecrets: []cli.DoctorSecret{ + {Name: "api-token", Label: "API token"}, + }, + SecretStoreFactory: func() (secretstore.Store, error) { + return secretstore.Open(secretstore.Options{ + ServiceName: "my-mcp", + }) + }, + ManifestDir: ".", + ConnectivityCheck: func(context.Context) cli.DoctorResult { + if err := pingBackend(); err != nil { + return cli.DoctorResult{ + Name: "connectivity", + Status: cli.DoctorStatusFail, + Summary: "backend is unreachable", + Detail: err.Error(), + } + } + return cli.DoctorResult{ + Name: "connectivity", + Status: cli.DoctorStatusOK, + Summary: "backend is reachable", + } + }, +}) + +if err := cli.RenderDoctorReport(os.Stdout, report); err != nil { + return err +} + +if report.HasFailures() { + os.Exit(1) +} +``` + ## Auto-Update Le package `update` ne déduit pas la forge ni l'authentification. diff --git a/cli/doctor.go b/cli/doctor.go new file mode 100644 index 0000000..ae2cdd9 --- /dev/null +++ b/cli/doctor.go @@ -0,0 +1,336 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "gitea.lclr.dev/AI/mcp-framework/config" + "gitea.lclr.dev/AI/mcp-framework/manifest" + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type DoctorStatus string + +const ( + DoctorStatusOK DoctorStatus = "ok" + DoctorStatusWarn DoctorStatus = "warn" + DoctorStatusFail DoctorStatus = "fail" +) + +type DoctorResult struct { + Name string + Status DoctorStatus + Summary string + Detail string +} + +type DoctorCheck func(context.Context) DoctorResult + +type DoctorReport struct { + Results []DoctorResult +} + +type DoctorSummary struct { + OK int + Warn int + Fail int + Total int +} + +type DoctorSecret struct { + Name string + Label string +} + +type DoctorManifestValidator func(manifest.File, string) []string + +type DoctorOptions struct { + ConfigCheck DoctorCheck + SecretStoreCheck DoctorCheck + RequiredSecrets []DoctorSecret + SecretStoreFactory func() (secretstore.Store, error) + ManifestDir string + ManifestValidator DoctorManifestValidator + ConnectivityCheck DoctorCheck + ExtraChecks []DoctorCheck +} + +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)) + } + checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator)) + if options.ConnectivityCheck != nil { + checks = append(checks, options.ConnectivityCheck) + } + 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 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 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" + } +} diff --git a/cli/doctor_test.go b/cli/doctor_test.go new file mode 100644 index 0000000..61ac464 --- /dev/null +++ b/cli/doctor_test.go @@ -0,0 +1,173 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.lclr.dev/AI/mcp-framework/config" + "gitea.lclr.dev/AI/mcp-framework/manifest" + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type doctorProfile struct { + BaseURL string `json:"base_url"` +} + +func TestRunDoctorAggregatesCommonChecksAndExtras(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempHome) + t.Setenv("HOME", tempHome) + + store := config.NewStore[doctorProfile]("doctor-test") + path, err := store.ConfigPath() + if err != nil { + t.Fatalf("ConfigPath returned error: %v", err) + } + + cfg := config.FileConfig[doctorProfile]{ + Version: config.CurrentVersion, + CurrentProfile: "default", + Profiles: map[string]doctorProfile{ + "default": {BaseURL: "https://api.example.com"}, + }, + } + if err := store.Save(path, cfg); err != nil { + t.Fatalf("Save returned error: %v", err) + } + + manifestDir := t.TempDir() + if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + secretFactory := func() (secretstore.Store, error) { + return secretstore.Open(secretstore.Options{ + BackendPolicy: secretstore.BackendEnvOnly, + LookupEnv: func(name string) (string, bool) { + if name == "api-token" { + return "secret", true + } + return "", false + }, + }) + } + + report := RunDoctor(context.Background(), DoctorOptions{ + ConfigCheck: NewConfigCheck(store), + SecretStoreCheck: SecretStoreAvailabilityCheck(secretFactory), + RequiredSecrets: []DoctorSecret{ + {Name: "api-token", Label: "API token"}, + }, + SecretStoreFactory: secretFactory, + ManifestDir: manifestDir, + ConnectivityCheck: func(context.Context) DoctorResult { + return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "service is reachable"} + }, + ExtraChecks: []DoctorCheck{ + func(context.Context) DoctorResult { + return DoctorResult{Name: "custom", Status: DoctorStatusWarn, Summary: "non blocking drift"} + }, + }, + }) + + summary := report.Summary() + if summary.OK != 5 || summary.Warn != 1 || summary.Fail != 0 || summary.Total != 6 { + t.Fatalf("summary = %#v", summary) + } +} + +func TestNewConfigCheckWarnsWhenConfigIsMissing(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempHome) + t.Setenv("HOME", tempHome) + + store := config.NewStore[doctorProfile]("doctor-test") + result := NewConfigCheck(store)(context.Background()) + + if result.Status != DoctorStatusWarn { + t.Fatalf("status = %q, want warn", result.Status) + } + if !strings.Contains(result.Detail, "config.json") { + t.Fatalf("detail = %q, want config path", result.Detail) + } +} + +func TestRequiredSecretsCheckFailsWhenSecretIsMissing(t *testing.T) { + check := RequiredSecretsCheck(func() (secretstore.Store, error) { + return secretstore.Open(secretstore.Options{ + BackendPolicy: secretstore.BackendEnvOnly, + LookupEnv: func(name string) (string, bool) { + return "", false + }, + }) + }, []DoctorSecret{{Name: "API_TOKEN", Label: "API token"}}) + + result := check(context.Background()) + if result.Status != DoctorStatusFail { + t.Fatalf("status = %q, want fail", result.Status) + } + if !strings.Contains(result.Detail, "API_TOKEN") { + t.Fatalf("detail = %q, want secret name", result.Detail) + } +} + +func TestManifestCheckUsesValidator(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "mcp.toml"), []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + result := ManifestCheck(dir, func(_ manifest.File, path string) []string { + _ = path + return []string{"missing business section"} + })(context.Background()) + + if result.Status != DoctorStatusFail { + t.Fatalf("status = %q, want fail", result.Status) + } +} + +func 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) + } +} -- 2.45.2 From 97d5f78009eaef73da145ee924ded18cd795abc5 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 13 Apr 2026 22:04:59 +0200 Subject: [PATCH 04/58] fix(ci): mark prerelease tags as prereleases --- .gitea/workflows/release.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 9d9bc5f..e67325a 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -55,16 +55,24 @@ jobs: current_tag="${GITHUB_REF_NAME}" api_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases" release_by_tag_url="${api_url}/tags/${current_tag}" + prerelease=false + + case "${current_tag}" in + *-rc*|*-beta*|*-alpha*) + prerelease=true + ;; + esac json_escape() { sed ':a;N;$!ba;s/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g;s/\r//g;s/\n/\\n/g' } body="$(json_escape < CHANGELOG.md)" - payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":false}' \ + payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \ "${current_tag}" \ "${current_tag}" \ - "${body}")" + "${body}" \ + "${prerelease}")" http_code="$( curl --silent --show-error --output release.json --write-out '%{http_code}' \ -- 2.45.2 From c0433d1cded736800c8a1af88834085bd55a41cd Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 08:17:58 +0200 Subject: [PATCH 05/58] docs(agents): enforce enhancement branch naming format --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1caefab..9cceb6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ Nommer la branche de manière explicite, par exemple : - `issue-8-update-drivers` - `docs-readme-installation` +Quand une branche est créée pour répondre à une issue et que cette issue porte le label `enhancement`, nommer la branche au format `feat/`. + Éviter de développer directement sur `main` quand le changement mérite une PR ou une validation fonctionnelle. ## Pull Requests -- 2.45.2 From 6badc79d4dab0c0108e072b4deefdc36e79afcfa Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 08:17:58 +0200 Subject: [PATCH 06/58] docs(agents): enforce enhancement branch naming format --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1caefab..9cceb6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ Nommer la branche de manière explicite, par exemple : - `issue-8-update-drivers` - `docs-readme-installation` +Quand une branche est créée pour répondre à une issue et que cette issue porte le label `enhancement`, nommer la branche au format `feat/`. + Éviter de développer directement sur `main` quand le changement mérite une PR ou une validation fonctionnelle. ## Pull Requests -- 2.45.2 From c70b3ac12b8d507a9231ea6bb292d5927133f450 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 08:24:24 +0200 Subject: [PATCH 07/58] feat: add optional CLI bootstrap package --- README.md | 52 ++++++-- bootstrap/bootstrap.go | 236 ++++++++++++++++++++++++++++++++++++ bootstrap/bootstrap_test.go | 182 +++++++++++++++++++++++++++ 3 files changed, 463 insertions(+), 7 deletions(-) create mode 100644 bootstrap/bootstrap.go create mode 100644 bootstrap/bootstrap_test.go diff --git a/README.md b/README.md index e8bcc12..3ffcb74 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ go get gitea.lclr.dev/AI/mcp-framework ## Packages +- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config`, `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 et conversion vers `update.ReleaseSource`. @@ -29,12 +30,50 @@ go get gitea.lclr.dev/AI/mcp-framework 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. -6. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. +1. Déclarer les sous-commandes communes via `bootstrap` (optionnel). +2. Résoudre le profil actif avec `cli`. +3. Charger la config versionnée avec `config`. +4. Lire les secrets avec `secretstore`. +5. Charger `mcp.toml` avec `manifest`. +6. Exécuter l'auto-update avec `update` si nécessaire. +7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. + +## Bootstrap CLI + +Le package `bootstrap` reste optionnel : une application peut l'utiliser pour +uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique. + +Exemple minimal : + +```go +func main() { + err := bootstrap.Run(context.Background(), bootstrap.Options{ + BinaryName: "my-mcp", + Description: "Client MCP", + Version: version, + Hooks: bootstrap.Hooks{ + Setup: func(ctx context.Context, inv bootstrap.Invocation) error { + return runSetup(ctx, inv.Args) + }, + MCP: func(ctx context.Context, inv bootstrap.Invocation) error { + return runMCP(ctx, inv.Args) + }, + Config: func(ctx context.Context, inv bootstrap.Invocation) error { + return runConfig(ctx, inv.Args) + }, + Update: func(ctx context.Context, inv bootstrap.Invocation) error { + return runUpdate(ctx, inv.Args) + }, + }, + }) + if err != nil { + log.Fatal(err) + } +} +``` + +Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche +automatiquement `Options.Version`. ## Manifeste `mcp.toml` @@ -371,5 +410,4 @@ func run(ctx context.Context, flagProfile string) error { ## 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 diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go new file mode 100644 index 0000000..7e4854d --- /dev/null +++ b/bootstrap/bootstrap.go @@ -0,0 +1,236 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" +) + +const ( + CommandSetup = "setup" + CommandMCP = "mcp" + CommandConfig = "config" + CommandUpdate = "update" + CommandVersion = "version" +) + +var ( + ErrBinaryNameRequired = errors.New("binary name is required") + ErrUnknownCommand = errors.New("unknown command") + ErrCommandNotConfigured = errors.New("command not configured") + ErrVersionRequired = errors.New("version is required when no version hook is configured") +) + +type Handler func(context.Context, Invocation) error + +type Hooks struct { + Setup Handler + MCP Handler + Config Handler + Update Handler + Version Handler +} + +type Options struct { + BinaryName string + Description string + Version 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: 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 + } + + command, commandArgs, showHelp := parseArgs(normalized.Args) + if showHelp { + return printHelp(normalized, command) + } + + if command == "" { + return printHelp(normalized, "") + } + + handler, known := resolveHandler(command, normalized.Hooks) + if !known { + return fmt.Errorf("%w: %s", ErrUnknownCommand, command) + } + + if handler == nil { + if command == CommandVersion { + if strings.TrimSpace(normalized.Version) == "" { + return ErrVersionRequired + } + _, err := fmt.Fprintln(normalized.Stdout, normalized.Version) + return err + } + return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command) + } + + return handler(ctx, Invocation{ + Command: command, + Args: commandArgs, + Stdin: normalized.Stdin, + Stdout: normalized.Stdout, + Stderr: normalized.Stderr, + }) +} + +func normalize(opts Options) Options { + if opts.Stdin == nil { + opts.Stdin = os.Stdin + } + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.Stderr == nil { + opts.Stderr = os.Stderr + } + if opts.Args == nil { + opts.Args = os.Args[1:] + } + return opts +} + +func parseArgs(args []string) (command string, commandArgs []string, showHelp bool) { + 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]), nil, true + } + return "", nil, true + } + + command = first + commandArgs = args[1:] + if len(commandArgs) == 1 { + helpArg := strings.TrimSpace(commandArgs[0]) + if helpArg == "-h" || helpArg == "--help" { + return command, nil, true + } + } + + return command, commandArgs, 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) error { + if command == "" { + return printGlobalHelp(opts) + } + + 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 printGlobalHelp(opts Options) error { + if strings.TrimSpace(opts.Description) != "" { + if _, err := fmt.Fprintf(opts.Stdout, "%s\n\n", opts.Description); err != nil { + return err + } + } + + if _, err := fmt.Fprintf(opts.Stdout, "Usage:\n %s [args]\n\n", opts.BinaryName); err != nil { + return err + } + if _, err := fmt.Fprintln(opts.Stdout, "Commandes communes:"); err != nil { + return err + } + + for _, def := range commands { + if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", def.Name, def.Description); err != nil { + return err + } + } + + _, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help \n", opts.BinaryName) + return err +} diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..b0c6b4d --- /dev/null +++ b/bootstrap/bootstrap_test.go @@ -0,0 +1,182 @@ +package bootstrap + +import ( + "bytes" + "context" + "errors" + "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 TestRunReturnsUnknownCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"boom"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrUnknownCommand) { + t.Fatalf("Run error = %v, want ErrUnknownCommand", err) + } +} + +func TestRunReturnsCommandNotConfigured(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrCommandNotConfigured) { + t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err) + } +} + +func TestRunPrintsVersionByDefault(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Version: "v1.2.3", + Args: []string{"version"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + if stdout.String() != "v1.2.3\n" { + t.Fatalf("stdout = %q, want %q", stdout.String(), "v1.2.3\n") + } +} + +func TestRunVersionHookOverridesDefault(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"version"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Version: func(_ context.Context, inv Invocation) error { + _, err := inv.Stdout.Write([]byte("custom-version\n")) + return err + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + if stdout.String() != "custom-version\n" { + t.Fatalf("stdout = %q, want %q", stdout.String(), "custom-version\n") + } +} + +func TestRunRequiresVersionWithoutVersionHook(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"version"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrVersionRequired) { + t.Fatalf("Run error = %v, want ErrVersionRequired", err) + } +} + +func TestRunPrintsGlobalHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Description: "Binaire MCP de test.", + Args: []string{"help"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + for _, snippet := range []string{ + "Usage:", + "setup", + "mcp", + "config", + "update", + "version", + } { + if !strings.Contains(text, snippet) { + t.Fatalf("help output missing %q: %s", snippet, text) + } + } +} + +func TestRunPrintsCommandHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"help", "update"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp update [args]") { + t.Fatalf("command help output = %q", text) + } + if !strings.Contains(text, "auto-update") { + t.Fatalf("command help output missing update description: %q", text) + } +} -- 2.45.2 From 76fa01c6696e342fde14f2fac319784c7ced99b3 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 08:55:01 +0200 Subject: [PATCH 08/58] feat(cli): standardize config resolution and provenance --- README.md | 49 ++++++++ cli/resolve.go | 283 ++++++++++++++++++++++++++++++++++++++++++ cli/resolve_test.go | 291 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 623 insertions(+) create mode 100644 cli/resolve.go create mode 100644 cli/resolve_test.go diff --git a/README.md b/README.md index 3ffcb74..51c79cd 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,55 @@ if err != nil { } ``` +Pour standardiser la résolution `flag > env > config > secret` avec provenance : + +```go +lookup := func(source cli.ValueSource, key string) (string, bool, error) { + switch source { + case cli.SourceFlag: + value, ok := flagValues[key] + return value, ok, nil + case cli.SourceEnv: + value, ok := os.LookupEnv(key) + return value, ok, nil + case cli.SourceConfig: + value, ok := configValues[key] + return value, ok, nil + case cli.SourceSecret: + 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 + } + default: + return "", false, nil + } +} + +resolution, err := cli.ResolveFields(cli.ResolveOptions{ + Fields: []cli.FieldSpec{ + {Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"}, + {Name: "api_token", Required: true, EnvKey: "MY_MCP_API_TOKEN", SecretKey: "my-mcp-api-token"}, + {Name: "timeout", DefaultValue: "30s"}, + }, + Lookup: lookup, +}) +if err != nil { + return err +} + +if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil { + return err +} +``` + +`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et +`FieldSpec.Sources` permet de définir un ordre spécifique pour un champ. + Le package fournit aussi un socle réutilisable pour une commande `doctor`. L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), mais peut réutiliser les checks communs et ajouter ses propres hooks : diff --git a/cli/resolve.go b/cli/resolve.go new file mode 100644 index 0000000..725571b --- /dev/null +++ b/cli/resolve.go @@ -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) +} diff --git a/cli/resolve_test.go b/cli/resolve_test.go new file mode 100644 index 0000000..27792fe --- /dev/null +++ b/cli/resolve_test.go @@ -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) + } +} + -- 2.45.2 From 34abd29bac838a10f3f29cf27ea7de4d41250678 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 09:17:45 +0200 Subject: [PATCH 09/58] feat(cli): add declarative typed setup engine --- README.md | 52 +++++ cli/setup.go | 530 ++++++++++++++++++++++++++++++++++++++++++++++ cli/setup_test.go | 265 +++++++++++++++++++++++ 3 files changed, 847 insertions(+) create mode 100644 cli/setup.go create mode 100644 cli/setup_test.go diff --git a/README.md b/README.md index 51c79cd..a935a50 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,58 @@ if err != nil { } ``` +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, // conserve la valeur existante si l'utilisateur laisse vide + }, + { + 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.") +} +``` + +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 diff --git a/cli/setup.go b/cli/setup.go new file mode 100644 index 0000000..77eb8bb --- /dev/null +++ b/cli/setup.go @@ -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 + } +} diff --git a/cli/setup_test.go b/cli/setup_test.go new file mode 100644 index 0000000..922079c --- /dev/null +++ b/cli/setup_test.go @@ -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 +} -- 2.45.2 From 1c40546f3f0b16e91328936c3eea71612fc78364 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 10:28:15 +0200 Subject: [PATCH 10/58] fix(ci): build changelog from last stable release tag --- .gitea/workflows/release.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e67325a..51d46b6 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -26,13 +26,19 @@ jobs: set -euo pipefail current_tag="${GITHUB_REF_NAME}" - previous_tag="" + previous_stable_tag="" - if previous_tag="$(git describe --tags --abbrev=0 "${current_tag}^" 2>/dev/null)"; then - range="${previous_tag}..${current_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}" { printf '## Changelog\n\n' - printf 'Changes since `%s`.\n\n' "${previous_tag}" + printf 'Changes since `%s`.\n\n' "${previous_stable_tag}" git log --reverse --pretty=format:'- %h %s' "${range}" printf '\n' } >CHANGELOG.md -- 2.45.2 From 89246d1581a13477f5b9fd105a92f16bebb84e89 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 10:52:36 +0200 Subject: [PATCH 11/58] feat(bootstrap): standardize config show/test command structure --- README.md | 12 ++- bootstrap/bootstrap.go | 164 +++++++++++++++++++++++++++++++++--- bootstrap/bootstrap_test.go | 140 +++++++++++++++++++++++++++++- 3 files changed, 298 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a935a50..501a9d2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ go get gitea.lclr.dev/AI/mcp-framework ## Packages -- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config`, `update`, `version`) et hooks métier explicites. +- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `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 et conversion vers `update.ReleaseSource`. @@ -58,8 +58,11 @@ func main() { MCP: func(ctx context.Context, inv bootstrap.Invocation) error { return runMCP(ctx, inv.Args) }, - Config: func(ctx context.Context, inv bootstrap.Invocation) error { - return runConfig(ctx, inv.Args) + ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error { + return runConfigShow(ctx, inv.Args) + }, + ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error { + return runConfigTest(ctx, inv.Args) }, Update: func(ctx context.Context, inv bootstrap.Invocation) error { return runUpdate(ctx, inv.Args) @@ -75,6 +78,9 @@ func main() { Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`. +Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). +La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). + ## Manifeste `mcp.toml` Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 7e4854d..7108942 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -15,23 +15,32 @@ const ( CommandConfig = "config" CommandUpdate = "update" CommandVersion = "version" + + 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 - MCP Handler - Config Handler - Update Handler - Version Handler + Setup Handler + MCP Handler + Config Handler + ConfigShow Handler + ConfigTest Handler + ConfigDelete Handler + Update Handler + Version Handler } type Options struct { @@ -106,13 +115,17 @@ func Run(ctx context.Context, opts Options) error { command, commandArgs, showHelp := parseArgs(normalized.Args) if showHelp { - return printHelp(normalized, command) + return printHelp(normalized, command, commandArgs) } if command == "" { return printHelp(normalized, "") } + if command == CommandConfig { + return runConfigCommand(ctx, normalized, commandArgs) + } + handler, known := resolveHandler(command, normalized.Hooks) if !known { return fmt.Errorf("%w: %s", ErrUnknownCommand, command) @@ -163,23 +176,96 @@ func parseArgs(args []string) (command string, commandArgs []string, showHelp bo switch first { case "help", "-h", "--help": if len(args) > 1 { - return strings.TrimSpace(args[1]), nil, true + return strings.TrimSpace(args[1]), trimArgs(args[2:]), true } return "", nil, true } command = first - commandArgs = args[1:] - if len(commandArgs) == 1 { - helpArg := strings.TrimSpace(commandArgs[0]) - if helpArg == "-h" || helpArg == "--help" { - return command, nil, true + 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 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", ErrSubcommandRequired, CommandConfig, ConfigSubcommandShow, ConfigSubcommandTest) + } + + 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 { @@ -189,11 +275,20 @@ func resolveHandler(command string, hooks Hooks) (Handler, bool) { return nil, false } -func printHelp(opts Options, command string) error { +func printHelp(opts Options, command string, args ...[]string) error { + var commandArgs []string + if len(args) > 0 { + commandArgs = args[0] + } + if command == "" { return printGlobalHelp(opts) } + if command == CommandConfig { + return printConfigHelp(opts, commandArgs) + } + for _, def := range commands { if def.Name != command { continue @@ -211,6 +306,49 @@ func printHelp(opts Options, command string) error { return fmt.Errorf("%w: %s", ErrUnknownCommand, command) } +func printConfigHelp(opts Options, args []string) error { + if len(args) == 0 { + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s config [args]\n\nSubcommandes:\n %-7s Afficher la configuration résolue et la provenance des valeurs.\n %-7s Vérifier la configuration et la connectivité.\n %-7s Supprimer un profil local (optionnel).\n", + opts.BinaryName, + ConfigSubcommandShow, + ConfigSubcommandTest, + ConfigSubcommandDelete, + ) + return err + } + + switch args[0] { + case ConfigSubcommandShow: + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s config %s [args]\n\nAfficher la configuration résolue et l'origine des valeurs.\n", + opts.BinaryName, + ConfigSubcommandShow, + ) + return err + case ConfigSubcommandTest: + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s config %s [args]\n\nTester la configuration résolue et la connectivité associée.\n", + opts.BinaryName, + ConfigSubcommandTest, + ) + return err + case ConfigSubcommandDelete: + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s config %s [args]\n\nSupprimer un profil local de configuration.\n", + opts.BinaryName, + ConfigSubcommandDelete, + ) + return err + default: + return fmt.Errorf("%w: %s %s", ErrUnknownSubcommand, CommandConfig, args[0]) + } +} + func printGlobalHelp(opts Options) error { if strings.TrimSpace(opts.Description) != "" { if _, err := fmt.Fprintf(opts.Stdout, "%s\n\n", opts.Description); err != nil { diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index b0c6b4d..29031ab 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -65,8 +65,8 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) { Stdout: &stdout, Stderr: &stderr, }) - if !errors.Is(err, ErrCommandNotConfigured) { - t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err) + if !errors.Is(err, ErrSubcommandRequired) { + t.Fatalf("Run error = %v, want ErrSubcommandRequired", err) } } @@ -158,6 +158,36 @@ func TestRunPrintsGlobalHelp(t *testing.T) { } } +func TestRunPrintsGlobalHelpWhenNoArgs(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Description: "Binaire MCP de test.", + Args: []string{}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + for _, snippet := range []string{ + "Usage:", + "setup", + "mcp", + "config", + "update", + "version", + } { + if !strings.Contains(text, snippet) { + t.Fatalf("help output missing %q: %s", snippet, text) + } + } +} + func TestRunPrintsCommandHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -180,3 +210,109 @@ func TestRunPrintsCommandHelp(t *testing.T) { t.Fatalf("command help output missing update description: %q", text) } } + +func TestRunPrintsConfigHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"help", "config"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + for _, snippet := range []string{ + "my-mcp config ", + "show", + "test", + } { + if !strings.Contains(text, snippet) { + t.Fatalf("config help output missing %q: %q", snippet, text) + } + } +} + +func TestRunPrintsConfigSubcommandHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"help", "config", "show"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp config show [args]") { + t.Fatalf("config subcommand help output = %q", text) + } +} + +func TestRunRoutesConfigShowHook(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + var got Invocation + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config", "show", "--profile", "prod"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + ConfigShow: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != "config show" { + t.Fatalf("invocation command = %q, want %q", got.Command, "config show") + } + wantArgs := []string{"--profile", "prod"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } +} + +func TestRunConfigShowReturnsCommandNotConfigured(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config", "show"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrCommandNotConfigured) { + t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err) + } +} + +func TestRunConfigReturnsUnknownSubcommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config", "sync"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrUnknownSubcommand) { + t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err) + } +} -- 2.45.2 From 17fe329ce982aa498ca60804e185ec59fef79013 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 12:30:56 +0200 Subject: [PATCH 12/58] feat(manifest): extend mcp.toml metadata sections --- README.md | 36 +++++++++++-- manifest/manifest.go | 107 +++++++++++++++++++++++++++++++++++--- manifest/manifest_test.go | 80 ++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 501a9d2..119084d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ go get gitea.lclr.dev/AI/mcp-framework - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `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 et conversion vers `update.ReleaseSource`. +- `manifest` : lecture de `mcp.toml` à la racine du projet, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. - `secretstore` : lecture/écriture de secrets dans le wallet natif. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. @@ -89,21 +89,48 @@ courant puis remonte les répertoires parents jusqu'à trouver le fichier. Exemple minimal : ```toml +binary_name = "my-mcp" +docs_url = "https://docs.example.com/my-mcp" + [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"] + +[environment] +known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"] + +[secret_store] +backend_policy = "auto" + +[profiles] +default = "prod" +known = ["dev", "staging", "prod"] + +[bootstrap] +description = "Client MCP interne" ``` -Champs supportés dans `[update]` : +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. - `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. +- `[environment].known` : variables d'environnement connues du projet. +- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`). +- `[profiles].default` : profil recommandé par défaut. +- `[profiles].known` : profils connus du projet. +- `[bootstrap].description` : description CLI utilisée par le bootstrap. + +Toutes ces sections (hors `[update]` selon les besoins de l'application) sont optionnelles. Exemple de chargement : @@ -115,6 +142,10 @@ if err != nil { fmt.Printf("manifest loaded from %s\n", path) source := file.Update.ReleaseSource() +bootstrapInfo := file.BootstrapInfo() +scaffoldInfo := file.ScaffoldInfo() +_ = bootstrapInfo +_ = scaffoldInfo ``` ## Config JSON @@ -516,5 +547,4 @@ func run(ctx context.Context, flagProfile string) error { ## Limites Actuelles -- le manifeste gère uniquement la section `[update]` - l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes diff --git a/manifest/manifest.go b/manifest/manifest.go index 2bb8ca1..21c0bcd 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -15,7 +15,13 @@ import ( const DefaultFile = "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"` } type Update struct { @@ -26,6 +32,40 @@ type Update struct { TokenEnvNames []string `toml:"token_env_names"` } +type Environment struct { + Known []string `toml:"known"` +} + +type SecretStore struct { + BackendPolicy string `toml:"backend_policy"` +} + +type Profiles struct { + Default string `toml:"default"` + Known []string `toml:"known"` +} + +type Bootstrap struct { + Description string `toml:"description"` +} + +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) { dir := strings.TrimSpace(startDir) if dir == "" { @@ -92,7 +132,13 @@ 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() } func (u *Update) normalize() { @@ -100,14 +146,24 @@ func (u *Update) normalize() { u.BaseURL = strings.TrimRight(strings.TrimSpace(u.BaseURL), "/") u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL) u.TokenHeader = strings.TrimSpace(u.TokenHeader) + u.TokenEnvNames = normalizeStringList(u.TokenEnvNames) +} - envNames := u.TokenEnvNames[:0] - for _, envName := range u.TokenEnvNames { - if trimmed := strings.TrimSpace(envName); trimmed != "" { - envNames = append(envNames, trimmed) - } - } - u.TokenEnvNames = envNames +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 (u Update) ReleaseSource() update.ReleaseSource { @@ -121,3 +177,38 @@ func (u Update) ReleaseSource() update.ReleaseSource { TokenEnvNames: append([]string(nil), u.TokenEnvNames...), } } + +func (f File) BootstrapInfo() BootstrapMetadata { + f.normalize() + + return BootstrapMetadata{ + BinaryName: f.BinaryName, + Description: f.Bootstrap.Description, + DocsURL: f.DocsURL, + DefaultProfile: f.Profiles.Default, + Profiles: append([]string(nil), f.Profiles.Known...), + } +} + +func (f File) ScaffoldInfo() ScaffoldMetadata { + f.normalize() + + return ScaffoldMetadata{ + BinaryName: f.BinaryName, + DocsURL: f.DocsURL, + KnownEnvironmentVariables: append([]string(nil), f.Environment.Known...), + SecretStorePolicy: f.SecretStore.BackendPolicy, + DefaultProfile: f.Profiles.Default, + Profiles: append([]string(nil), f.Profiles.Known...), + } +} + +func normalizeStringList(values []string) []string { + normalized := values[:0] + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + normalized = append(normalized, trimmed) + } + } + return normalized +} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index cfd35ec..ebc9454 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "slices" "testing" ) @@ -114,3 +115,82 @@ 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) + } +} -- 2.45.2 From cae689d0e4415367a6f734a8281b0231452d7e26 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 13:55:00 +0200 Subject: [PATCH 13/58] fix(bootstrap): include delete in config subcommand hint --- bootstrap/bootstrap.go | 9 ++++++++- bootstrap/bootstrap_test.go | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 7108942..3a14226 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -211,7 +211,14 @@ func trimArgs(args []string) []string { func runConfigCommand(ctx context.Context, opts Options, args []string) error { if len(args) == 0 { - return fmt.Errorf("%w: %s requires one of: %s, %s", ErrSubcommandRequired, CommandConfig, ConfigSubcommandShow, ConfigSubcommandTest) + return fmt.Errorf( + "%w: %s requires one of: %s, %s, %s", + ErrSubcommandRequired, + CommandConfig, + ConfigSubcommandShow, + ConfigSubcommandTest, + ConfigSubcommandDelete, + ) } subcommand := strings.TrimSpace(args[0]) diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index 29031ab..d3d34c1 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -68,6 +68,9 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) { 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) { -- 2.45.2 From bf8e1285d874dc4746825d631093c725071c9842 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 14:11:43 +0200 Subject: [PATCH 14/58] feat(update): add forge drivers and checksum validation hooks --- README.md | 36 +- manifest/manifest.go | 37 +- manifest/manifest_test.go | 24 ++ update/update.go | 551 +++++++++++++++++++++++++++--- update/update_test.go | 693 +++++++++++++++++++++++++++++++++++++- 5 files changed, 1267 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 119084d..ce2a5be 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,14 @@ docs_url = "https://docs.example.com/my-mcp" [update] source_name = "Gitea releases" +driver = "gitea" +repository = "org/repo" base_url = "https://gitea.example.com" -latest_release_url = "https://gitea.example.com/api/v1/repos/org/repo/releases/latest" +asset_name_template = "{binary}-{os}-{arch}{ext}" +checksum_asset_name = "{asset}.sha256" +checksum_required = false token_header = "Authorization" +token_prefix = "token" token_env_names = ["GITEA_TOKEN"] [environment] @@ -120,9 +125,15 @@ Champs supportés : - `[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. +- `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. - `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`). @@ -462,9 +473,14 @@ if report.HasFailures() { ## Auto-Update -Le package `update` ne déduit pas la forge ni l'authentification. -L'application cliente fournit l'URL de release, le header d'auth éventuel et, -si besoin, les variables d'environnement à consulter. +Le 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 actuellement : @@ -501,12 +517,12 @@ if err != nil { } ``` -Contraintes actuelles : +Comportement : -- 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` +- le nom de l'asset est configurable (`asset_name_template`) et supporte tout couple `GOOS/GOARCH` +- si un asset `.sha256` (ou `checksum_asset_name`) existe, le binaire téléchargé est vérifié avant remplacement +- un hook `ValidateDownloaded` permet d'ajouter une validation custom (signature, scan, etc.) +- sur Windows, le remplacement in-place n'est pas fait par défaut ; fournir `Options.ReplaceExecutable` pour une stratégie dédiée ## Exemple Minimal diff --git a/manifest/manifest.go b/manifest/manifest.go index 21c0bcd..b3c9414 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -25,11 +25,17 @@ type File struct { } 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"` + TokenHeader string `toml:"token_header"` + TokenPrefix string `toml:"token_prefix"` + TokenEnvNames []string `toml:"token_env_names"` } type Environment struct { @@ -143,9 +149,14 @@ func (f *File) 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.TokenHeader = strings.TrimSpace(u.TokenHeader) + u.TokenPrefix = strings.TrimSpace(u.TokenPrefix) u.TokenEnvNames = normalizeStringList(u.TokenEnvNames) } @@ -170,11 +181,17 @@ 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, + TokenHeader: u.TokenHeader, + TokenPrefix: u.TokenPrefix, + TokenEnvNames: append([]string(nil), u.TokenEnvNames...), } } diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index ebc9454..83ea7d5 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -46,9 +46,15 @@ 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 token_header = " Authorization " +token_prefix = " token " token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"] ` @@ -65,15 +71,33 @@ 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.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) } diff --git a/update/update.go b/update/update.go index 12593d8..7ba393a 100644 --- a/update/update.go +++ b/update/update.go @@ -2,6 +2,8 @@ package update import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -9,31 +11,57 @@ import ( "net/http" "net/url" "os" + "path" "path/filepath" "runtime" + "sort" "strings" "time" ) +const defaultAssetNameTemplate = "{binary}-{os}-{arch}{ext}" + 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 + 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 + Token string + TokenHeader string + TokenPrefix string + TokenEnvNames []string } type Auth struct { @@ -53,6 +81,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 @@ -73,12 +128,9 @@ func Run(ctx context.Context, opts Options) error { 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 +138,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 } @@ -111,7 +168,28 @@ func Run(ctx context.Context, opts Options) error { } defer os.Remove(downloadPath) - if err := ReplaceExecutable(downloadPath, targetPath); err != nil { + if err := VerifyReleaseAssetChecksum(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 +197,55 @@ func Run(ctx context.Context, opts Options) error { return nil } +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 +270,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,7 +372,26 @@ 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) { @@ -292,9 +451,57 @@ 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 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 +511,69 @@ 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.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 + + 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 +642,211 @@ 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 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 matchesAssetName(candidate, assetName string) bool { + name := strings.TrimSpace(strings.TrimPrefix(candidate, "*")) + name = strings.TrimPrefix(name, "./") + if name == assetName { + return true + } + + name = strings.ReplaceAll(name, "\\", "/") + if path.Base(name) == assetName { + return true + } + return false +} + +func isSHA256Hex(value string) bool { + if len(value) != 64 { + return false + } + for _, r := range value { + switch { + case r >= '0' && r <= '9': + case r >= 'a' && r <= 'f': + case r >= 'A' && r <= 'F': + default: + return false + } + } + return true +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open downloaded artifact: %w", err) + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", fmt.Errorf("hash downloaded artifact: %w", err) + } + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/update/update_test.go b/update/update_test.go index 047ac74..ab427e3 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -3,7 +3,10 @@ package update import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "io" "net/http" "os" @@ -24,13 +27,20 @@ func TestAssetName(t *testing.T) { {name: "darwin amd64", goos: "darwin", goarch: "amd64", want: "graylog-mcp-darwin-amd64"}, {name: "darwin arm64", goos: "darwin", goarch: "arm64", want: "graylog-mcp-darwin-arm64"}, {name: "linux amd64", goos: "linux", goarch: "amd64", want: "graylog-mcp-linux-amd64"}, - {name: "windows amd64", goos: "windows", goarch: "amd64", want: "graylog-mcp-windows-amd64.exe"}, - {name: "unsupported", goos: "linux", goarch: "arm64", wantErr: "no release artifact"}, + {name: "linux arm64", goos: "linux", goarch: "arm64", want: "graylog-mcp-linux-arm64"}, + {name: "windows arm64", goos: "windows", goarch: "arm64", want: "graylog-mcp-windows-arm64.exe"}, + {name: "missing binary", goos: "linux", goarch: "amd64", wantErr: "binary name must not be empty"}, + {name: "missing platform", goos: "", goarch: "amd64", wantErr: "goos and goarch must not be empty"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := AssetName("graylog-mcp", tt.goos, tt.goarch) + binaryName := "graylog-mcp" + if tt.name == "missing binary" { + binaryName = " " + } + + got, err := AssetName(binaryName, tt.goos, tt.goarch) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %v, want substring %q", err, tt.wantErr) @@ -47,6 +57,91 @@ func TestAssetName(t *testing.T) { } } +func TestAssetNameWithTemplate(t *testing.T) { + got, err := AssetNameWithTemplate("graylog-mcp", "linux", "amd64", "{binary}_{os}_{arch}") + if err != nil { + t.Fatalf("AssetNameWithTemplate: %v", err) + } + if got != "graylog-mcp_linux_amd64" { + t.Fatalf("got %q", got) + } + + _, err = AssetNameWithTemplate("graylog-mcp", "linux", "amd64", "{binary}/{os}/{arch}") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "must not contain path separators") { + t.Fatalf("error = %v", err) + } +} + +func TestResolveLatestReleaseURL(t *testing.T) { + got, err := ResolveLatestReleaseURL("https://custom/latest", ReleaseSource{ + Driver: "gitea", + Repository: "org/repo", + BaseURL: "https://gitea.example.com", + }) + if err != nil { + t.Fatalf("ResolveLatestReleaseURL explicit: %v", err) + } + if got != "https://custom/latest" { + t.Fatalf("release url = %q", got) + } + + got, err = ResolveLatestReleaseURL("", ReleaseSource{ + LatestReleaseURL: "https://manifest/latest", + }) + if err != nil { + t.Fatalf("ResolveLatestReleaseURL from source: %v", err) + } + if got != "https://manifest/latest" { + t.Fatalf("release url = %q", got) + } + + got, err = ResolveLatestReleaseURL("", ReleaseSource{ + Driver: "gitea", + Repository: "org/repo", + BaseURL: "https://gitea.example.com", + }) + if err != nil { + t.Fatalf("ResolveLatestReleaseURL gitea: %v", err) + } + if got != "https://gitea.example.com/api/v1/repos/org/repo/releases/latest" { + t.Fatalf("release url = %q", got) + } + + got, err = ResolveLatestReleaseURL("", ReleaseSource{ + Driver: "gitlab", + Repository: "group/sub/repo", + BaseURL: "https://gitlab.example.com", + }) + if err != nil { + t.Fatalf("ResolveLatestReleaseURL gitlab: %v", err) + } + if got != "https://gitlab.example.com/api/v4/projects/group%2Fsub%2Frepo/releases/permalink/latest" { + t.Fatalf("release url = %q", got) + } + + got, err = ResolveLatestReleaseURL("", ReleaseSource{ + Driver: "github", + Repository: "org/repo", + }) + if err != nil { + t.Fatalf("ResolveLatestReleaseURL github: %v", err) + } + if got != "https://api.github.com/repos/org/repo/releases/latest" { + t.Fatalf("release url = %q", got) + } + + _, err = ResolveLatestReleaseURL("", ReleaseSource{Driver: "gitea"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "requires repository") { + t.Fatalf("error = %v", err) + } +} + func TestResolveUpdateTargetFollowsSymlink(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink behavior differs on windows") @@ -87,20 +182,37 @@ func TestReleaseAssetURLResolvesRelativeLinks(t *testing.T) { } } +func TestReleaseAssetURLErrorIncludesAvailableAssets(t *testing.T) { + release := Release{} + release.Assets.Links = []ReleaseLink{ + {Name: "my-mcp-linux-amd64", URL: "/downloads/my-mcp-linux-amd64"}, + {Name: "my-mcp-darwin-arm64", URL: "/downloads/my-mcp-darwin-arm64"}, + } + + _, err := release.AssetURL("my-mcp-linux-arm64", "https://releases.example.com/latest") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "available: my-mcp-darwin-arm64, my-mcp-linux-amd64") { + t.Fatalf("error = %v", err) + } +} + func TestResolveAuthPrefersExplicitToken(t *testing.T) { t.Setenv("RELEASE_TOKEN", "env-token") auth := ResolveAuth("explicit-token", ReleaseSource{ - Name: "release endpoint", - BaseURL: "https://releases.example.com", - TokenHeader: "X-Release-Token", - TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, + TokenHeader: "Authorization", + TokenPrefix: "Bearer", + TokenEnvNames: []string{ + "RELEASE_TOKEN", + }, }) - if auth.Header != "X-Release-Token" { - t.Fatalf("header = %q, want X-Release-Token", auth.Header) + if auth.Header != "Authorization" { + t.Fatalf("header = %q, want Authorization", auth.Header) } - if auth.Token != "explicit-token" { - t.Fatalf("token = %q, want explicit token", auth.Token) + if auth.Token != "Bearer explicit-token" { + t.Fatalf("token = %q, want prefixed explicit token", auth.Token) } } @@ -108,10 +220,11 @@ func TestResolveAuthReadsEnvironment(t *testing.T) { t.Setenv("RELEASE_PRIVATE_TOKEN", "env-token") auth := ResolveAuth("", ReleaseSource{ - Name: "release endpoint", - BaseURL: "https://releases.example.com", - TokenHeader: "X-Release-Token", - TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, + TokenHeader: "X-Release-Token", + TokenEnvNames: []string{ + "RELEASE_TOKEN", + "RELEASE_PRIVATE_TOKEN", + }, }) if auth.Header != "X-Release-Token" { t.Fatalf("header = %q, want X-Release-Token", auth.Header) @@ -155,6 +268,86 @@ func TestFetchLatestReleaseAddsConfiguredAuthHeader(t *testing.T) { } } +func TestFetchLatestReleaseSupportsGitHubAssets(t *testing.T) { + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + body := `{ + "tag_name":"v1.2.3", + "assets":[ + {"name":"my-mcp-linux-amd64","browser_download_url":"https://example.com/my-mcp-linux-amd64"} + ] + }` + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }), + } + + release, err := FetchLatestRelease( + context.Background(), + client, + "https://api.github.com/repos/org/repo/releases/latest", + Auth{}, + ReleaseSource{Name: "GitHub releases"}, + ) + if err != nil { + t.Fatalf("FetchLatestRelease: %v", err) + } + + assetURL, err := release.AssetURL("my-mcp-linux-amd64", "https://api.github.com/repos/org/repo/releases/latest") + if err != nil { + t.Fatalf("AssetURL: %v", err) + } + if assetURL != "https://example.com/my-mcp-linux-amd64" { + t.Fatalf("assetURL = %q", assetURL) + } +} + +func TestFetchLatestReleaseSupportsGitLabDirectAssetURL(t *testing.T) { + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + body := `{ + "tag_name":"v1.2.3", + "assets":{ + "links":[ + { + "name":"my-mcp-linux-amd64", + "url":"https://gitlab.example.com/fallback", + "direct_asset_url":"https://gitlab.example.com/direct/my-mcp-linux-amd64" + } + ] + } + }` + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }), + } + + release, err := FetchLatestRelease( + context.Background(), + client, + "https://gitlab.example.com/api/v4/projects/org%2Frepo/releases/permalink/latest", + Auth{}, + ReleaseSource{Name: "GitLab releases"}, + ) + if err != nil { + t.Fatalf("FetchLatestRelease: %v", err) + } + + assetURL, err := release.AssetURL("my-mcp-linux-amd64", "https://gitlab.example.com/api/v4/projects/org%2Frepo/releases/permalink/latest") + if err != nil { + t.Fatalf("AssetURL: %v", err) + } + if assetURL != "https://gitlab.example.com/direct/my-mcp-linux-amd64" { + t.Fatalf("assetURL = %q", assetURL) + } +} + func TestFetchLatestReleaseHintsWhenAuthIsMissing(t *testing.T) { client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { @@ -282,6 +475,474 @@ func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { } } +func TestRunUsesDriverWithoutExplicitLatestReleaseURL(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + latestURL := "https://gitea.example.com/api/v1/repos/org/graylog-mcp/releases/latest" + replaceCalled := false + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case latestURL: + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://gitea.example.com/downloads/artifact"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://gitea.example.com/downloads/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + BinaryName: "graylog-mcp", + ReleaseSource: ReleaseSource{ + Driver: "gitea", + Repository: "org/graylog-mcp", + BaseURL: "https://gitea.example.com", + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + replaceCalled = true + + data, err := os.ReadFile(downloadPath) + if err != nil { + return err + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + + if !replaceCalled { + t.Fatal("custom replace hook was not called") + } +} + +func TestRunVerifiesChecksumWhenSidecarAvailable(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + const newBinary = "new-binary" + hash := sha256.Sum256([]byte(newBinary)) + checksum := hex.EncodeToString(hash[:]) + " " + assetName + "\n" + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + {Name: assetName + ".sha256", URL: "https://releases.example.com/artifact.sha256"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(newBinary)), + }, nil + case "https://releases.example.com/artifact.sha256": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(checksum)), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReplaceExecutable: func(downloadPath, targetPath string) error { + data, err := os.ReadFile(downloadPath) + if err != nil { + return err + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("ReadFile target: %v", err) + } + if string(got) != newBinary { + t.Fatalf("target content = %q, want %q", string(got), newBinary) + } +} + +func TestRunFailsOnChecksumMismatch(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + {Name: assetName + ".sha256", URL: "https://releases.example.com/artifact.sha256"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + case "https://releases.example.com/artifact.sha256": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa " + assetName)), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReplaceExecutable: func(downloadPath, targetPath string) error { + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("error = %v", err) + } +} + +func TestRunFailsWhenChecksumRequiredAndMissing(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReleaseSource: ReleaseSource{ + ChecksumRequired: true, + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "checksum verification") { + t.Fatalf("error = %v", err) + } +} + +func TestRunInvokesValidationHook(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + validateCalled := false + replaceCalled := false + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ValidateDownloaded: func(_ context.Context, input ValidationInput) error { + validateCalled = true + if input.AssetName != assetName { + t.Fatalf("asset name = %q", input.AssetName) + } + data, readErr := os.ReadFile(input.DownloadPath) + if readErr != nil { + return readErr + } + if string(data) != "new-binary" { + t.Fatalf("downloaded content = %q", string(data)) + } + return nil + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + replaceCalled = true + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + + if !validateCalled { + t.Fatal("validation hook was not called") + } + if !replaceCalled { + t.Fatal("replace hook was not called") + } +} + +func TestRunStopsWhenValidationHookFails(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + replaceCalled := false + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ValidateDownloaded: func(context.Context, ValidationInput) error { + return errors.New("signature invalid") + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + replaceCalled = true + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "validate downloaded artifact") { + t.Fatalf("error = %v", err) + } + if replaceCalled { + t.Fatal("replace hook should not have been called") + } +} + func TestRunSkipsWhenAlreadyOnLatestRelease(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { @@ -364,7 +1025,7 @@ func TestRunSkipsWhenAlreadyOnLatestRelease(t *testing.T) { } } -func TestRunRequiresLatestReleaseURL(t *testing.T) { +func TestRunRequiresLatestReleaseURLOrDriver(t *testing.T) { target := filepath.Join(t.TempDir(), "graylog-mcp") if err := os.WriteFile(target, []byte("current-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) -- 2.45.2 From c4c461105fb8b53785d931b2a1ffd096c555ff51 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 15:39:21 +0200 Subject: [PATCH 15/58] feat(scaffold): add MCP binary scaffold generator --- README.md | 27 ++ scaffold/scaffold.go | 738 ++++++++++++++++++++++++++++++++++++++ scaffold/scaffold_test.go | 183 ++++++++++ 3 files changed, 948 insertions(+) create mode 100644 scaffold/scaffold.go create mode 100644 scaffold/scaffold_test.go diff --git a/README.md b/README.md index ce2a5be..72270a8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ go get gitea.lclr.dev/AI/mcp-framework - `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, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. +- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, wiring de base et README de démarrage). - `secretstore` : lecture/écriture de secrets dans le wallet natif. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. @@ -159,6 +160,32 @@ _ = bootstrapInfo _ = scaffoldInfo ``` +## Scaffolding + +Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : + +- arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) +- wiring initial `bootstrap + config + secretstore + update` +- `README.md` de démarrage + +Exemple : + +```go +result, err := scaffold.Generate(scaffold.Options{ + TargetDir: "./my-mcp", + ModulePath: "gitea.lclr.dev/AI/my-mcp", + BinaryName: "my-mcp", + Description: "Client MCP interne", + DefaultProfile: "prod", + Profiles: []string{"dev", "prod"}, +}) +if err != nil { + return err +} + +fmt.Printf("Scaffold generated in %s (%d files)\n", result.Root, len(result.Files)) +``` + ## Config JSON Le package `config` stocke une structure générique par profil dans un JSON privé diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go new file mode 100644 index 0000000..06b5da4 --- /dev/null +++ b/scaffold/scaffold.go @@ -0,0 +1,738 @@ +package scaffold + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "text/template" + "unicode" +) + +var ( + ErrTargetDirRequired = errors.New("target directory is required") + ErrFileExists = errors.New("target file already exists") +) + +type Options struct { + TargetDir string + ModulePath string + BinaryName string + Description string + DocsURL string + DefaultProfile string + Profiles []string + KnownEnvironmentVariables []string + SecretStorePolicy string + ReleaseDriver string + ReleaseBaseURL string + ReleaseRepository string + ReleaseTokenEnv string + Overwrite bool +} + +type Result struct { + Root string + Files []string +} + +type normalizedOptions struct { + TargetDir string + ModulePath string + BinaryName string + Description string + DocsURL string + DefaultProfile string + Profiles []string + KnownEnvironmentVariables []string + ProfileEnv string + BaseURLEnv string + TokenEnv string + SecretStorePolicy string + ReleaseDriver string + ReleaseBaseURL string + ReleaseRepository string + ReleaseTokenEnv string + Overwrite bool +} + +func Generate(options Options) (Result, error) { + normalized, err := normalizeOptions(options) + if err != nil { + return Result{}, err + } + + if err := os.MkdirAll(normalized.TargetDir, 0o755); err != nil { + return Result{}, fmt.Errorf("create scaffold target %q: %w", normalized.TargetDir, err) + } + + files := []generatedFile{ + {Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized)}, + {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized)}, + {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized)}, + {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized)}, + {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized)}, + {Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized)}, + } + + written := make([]string, 0, len(files)) + for _, file := range files { + fullPath := filepath.Join(normalized.TargetDir, file.Path) + if err := writeFile(fullPath, file.Content, normalized.Overwrite); err != nil { + return Result{}, err + } + written = append(written, file.Path) + } + + sort.Strings(written) + return Result{ + Root: normalized.TargetDir, + Files: written, + }, nil +} + +type generatedFile struct { + Path string + Content string +} + +func writeFile(path, content string, overwrite bool) error { + if !overwrite { + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("%w: %s", ErrFileExists, path) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat scaffold file %q: %w", path, err) + } + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create scaffold directory %q: %w", dir, err) + } + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("write scaffold file %q: %w", path, err) + } + + return nil +} + +func renderTemplate(src string, data normalizedOptions) string { + tpl := template.Must(template.New("scaffold").Parse(src)) + + var builder strings.Builder + if err := tpl.Execute(&builder, data); err != nil { + panic(err) + } + + return builder.String() +} + +func normalizeOptions(options Options) (normalizedOptions, error) { + targetDir := strings.TrimSpace(options.TargetDir) + if targetDir == "" { + return normalizedOptions{}, ErrTargetDirRequired + } + + resolvedTarget, err := filepath.Abs(targetDir) + if err != nil { + return normalizedOptions{}, fmt.Errorf("resolve scaffold target %q: %w", targetDir, err) + } + + binaryName := strings.TrimSpace(options.BinaryName) + if binaryName == "" { + binaryName = sanitizeSlug(filepath.Base(resolvedTarget)) + } + if binaryName == "" { + binaryName = "my-mcp" + } + if strings.ContainsRune(binaryName, os.PathSeparator) { + return normalizedOptions{}, fmt.Errorf("binary name %q must not contain path separators", binaryName) + } + + modulePath := strings.TrimSpace(options.ModulePath) + if modulePath == "" { + modulePath = fmt.Sprintf("example.com/%s", sanitizeModuleSegment(binaryName)) + } + + description := strings.TrimSpace(options.Description) + if description == "" { + description = fmt.Sprintf("Binaire MCP %s.", binaryName) + } + + docsURL := strings.TrimSpace(options.DocsURL) + if docsURL == "" { + docsURL = fmt.Sprintf("https://docs.example.com/%s", binaryName) + } + + defaultProfile := strings.TrimSpace(options.DefaultProfile) + if defaultProfile == "" { + defaultProfile = "default" + } + + profiles := normalizeValues(options.Profiles) + if !slices.Contains(profiles, defaultProfile) { + profiles = append([]string{defaultProfile}, profiles...) + } + if len(profiles) == 0 { + profiles = []string{defaultProfile} + } + + envPrefix := environmentPrefix(binaryName) + profileEnv := envPrefix + "_PROFILE" + baseURLEnv := envPrefix + "_BASE_URL" + tokenEnv := envPrefix + "_API_TOKEN" + + knownEnvironmentVariables := []string{profileEnv, baseURLEnv, tokenEnv} + for _, name := range normalizeValues(options.KnownEnvironmentVariables) { + if !slices.Contains(knownEnvironmentVariables, name) { + knownEnvironmentVariables = append(knownEnvironmentVariables, name) + } + } + + secretStorePolicy := strings.TrimSpace(options.SecretStorePolicy) + if secretStorePolicy == "" { + secretStorePolicy = "auto" + } + + releaseDriver := strings.TrimSpace(options.ReleaseDriver) + if releaseDriver == "" { + releaseDriver = "gitea" + } + + releaseBaseURL := strings.TrimSpace(options.ReleaseBaseURL) + if releaseBaseURL == "" { + releaseBaseURL = "https://gitea.example.com" + } + + releaseRepository := strings.Trim(strings.TrimSpace(options.ReleaseRepository), "/") + if releaseRepository == "" { + releaseRepository = fmt.Sprintf("org/%s", binaryName) + } + + releaseTokenEnv := strings.TrimSpace(options.ReleaseTokenEnv) + if releaseTokenEnv == "" { + releaseTokenEnv = envPrefix + "_RELEASE_TOKEN" + } + + return normalizedOptions{ + TargetDir: resolvedTarget, + ModulePath: modulePath, + BinaryName: binaryName, + Description: description, + DocsURL: docsURL, + DefaultProfile: defaultProfile, + Profiles: profiles, + KnownEnvironmentVariables: knownEnvironmentVariables, + ProfileEnv: profileEnv, + BaseURLEnv: baseURLEnv, + TokenEnv: tokenEnv, + SecretStorePolicy: secretStorePolicy, + ReleaseDriver: releaseDriver, + ReleaseBaseURL: releaseBaseURL, + ReleaseRepository: releaseRepository, + ReleaseTokenEnv: releaseTokenEnv, + Overwrite: options.Overwrite, + }, nil +} + +func normalizeValues(values []string) []string { + normalized := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + normalized = append(normalized, trimmed) + } + return normalized +} + +func sanitizeSlug(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + + var builder strings.Builder + lastDash := false + for _, r := range value { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + builder.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || r == ' ' || r == '.': + if !lastDash && builder.Len() > 0 { + builder.WriteRune('-') + lastDash = true + } + } + } + + result := strings.Trim(builder.String(), "-") + if result == "" { + return "my-mcp" + } + + return result +} + +func sanitizeModuleSegment(binaryName string) string { + segment := sanitizeSlug(binaryName) + if segment == "" { + return "my-mcp" + } + return segment +} + +func environmentPrefix(binaryName string) string { + name := strings.ToUpper(strings.TrimSpace(binaryName)) + if name == "" { + return "MCP" + } + + var builder strings.Builder + lastUnderscore := false + for _, r := range name { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + builder.WriteRune(r) + lastUnderscore = false + default: + if !lastUnderscore { + builder.WriteRune('_') + lastUnderscore = true + } + } + } + + result := strings.Trim(builder.String(), "_") + if result == "" { + return "MCP" + } + return result +} + +const gitignoreTemplate = `bin/ +dist/ +*.log +` + +const goModTemplate = `module {{.ModulePath}} + +go 1.25.0 +` + +const mainTemplate = `package main + +import ( + "context" + "log" + "os" + + "{{.ModulePath}}/internal/app" +) + +var version = "dev" + +func main() { + if err := app.Run(context.Background(), os.Args[1:], version); err != nil { + log.Fatal(err) + } +} +` + +const appTemplate = `package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "gitea.lclr.dev/AI/mcp-framework/bootstrap" + "gitea.lclr.dev/AI/mcp-framework/cli" + "gitea.lclr.dev/AI/mcp-framework/config" + "gitea.lclr.dev/AI/mcp-framework/manifest" + "gitea.lclr.dev/AI/mcp-framework/secretstore" + "gitea.lclr.dev/AI/mcp-framework/update" +) + +type Profile struct { + BaseURL string +} + +type Runtime struct { + ConfigStore config.Store[Profile] + Manifest manifest.File + BinaryName string + Description string + Version string + DefaultProfile string + ProfileEnv string + TokenEnv string + SecretName string + SecretStorePolicy string +} + +func Run(ctx context.Context, args []string, version string) error { + runtime, err := NewRuntime(version) + if err != nil { + return err + } + + return runtime.Run(ctx, args) +} + +func NewRuntime(version string) (Runtime, error) { + manifestFile, _, err := manifest.LoadDefault(".") + if err != nil { + return Runtime{}, err + } + + bootstrapInfo := manifestFile.BootstrapInfo() + scaffoldInfo := manifestFile.ScaffoldInfo() + + binaryName := firstNonEmpty(bootstrapInfo.BinaryName, "{{.BinaryName}}") + description := firstNonEmpty(bootstrapInfo.Description, "{{.Description}}") + defaultProfile := firstNonEmpty(scaffoldInfo.DefaultProfile, "{{.DefaultProfile}}") + profileEnv := "{{.ProfileEnv}}" + tokenEnv := "{{.TokenEnv}}" + if len(scaffoldInfo.KnownEnvironmentVariables) > 0 { + profileEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[0], profileEnv) + } + if len(scaffoldInfo.KnownEnvironmentVariables) > 2 { + tokenEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[2], tokenEnv) + } + + return Runtime{ + ConfigStore: config.NewStore[Profile](binaryName), + Manifest: manifestFile, + BinaryName: binaryName, + Description: description, + Version: firstNonEmpty(strings.TrimSpace(version), "dev"), + DefaultProfile: defaultProfile, + ProfileEnv: profileEnv, + TokenEnv: tokenEnv, + SecretName: binaryName + "-api-token", + SecretStorePolicy: firstNonEmpty(scaffoldInfo.SecretStorePolicy, "{{.SecretStorePolicy}}"), + }, nil +} + +func (r Runtime) Run(ctx context.Context, args []string) error { + return bootstrap.Run(ctx, bootstrap.Options{ + BinaryName: r.BinaryName, + Description: r.Description, + Version: r.Version, + Args: args, + Hooks: bootstrap.Hooks{ + Setup: r.runSetup, + MCP: r.runMCP, + ConfigShow: r.runConfigShow, + ConfigTest: r.runConfigTest, + Update: r.runUpdate, + }, + }) +} + +func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { + stdin, ok := inv.Stdin.(*os.File) + if !ok || stdin == nil { + stdin = os.Stdin + } + + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + cfg, _, err := r.ConfigStore.LoadDefault() + if err != nil { + return err + } + + profileName := r.resolveProfileName(cfg.CurrentProfile) + profile := cfg.Profiles[profileName] + storedToken, _ := r.readToken() + + result, err := cli.RunSetup(cli.SetupOptions{ + Stdin: stdin, + Stdout: stdout, + Fields: []cli.SetupField{ + { + Name: "base_url", + Label: "Base URL", + Type: cli.SetupFieldURL, + Required: true, + Default: profile.BaseURL, + }, + { + Name: "api_token", + Label: "API token", + Type: cli.SetupFieldSecret, + Required: true, + ExistingSecret: storedToken, + }, + }, + }) + if err != nil { + return err + } + + baseURLValue, _ := result.Get("base_url") + tokenValue, _ := result.Get("api_token") + + profile.BaseURL = strings.TrimSpace(baseURLValue.String) + cfg.CurrentProfile = profileName + cfg.Profiles[profileName] = profile + if _, err := r.ConfigStore.SaveDefault(cfg); err != nil { + return err + } + + if !tokenValue.KeptStoredSecret { + store, err := r.openSecretStore() + if err != nil { + return err + } + + if err := store.SetSecret(r.SecretName, "API token", tokenValue.String); err != nil { + if errors.Is(err, secretstore.ErrReadOnly) { + fmt.Fprintf(stdout, "Secret store en lecture seule, exporte %s pour fournir le token.\n", r.TokenEnv) + } else { + return err + } + } + } + + _, err = fmt.Fprintf(stdout, "Configuration sauvegardée pour le profil %q.\n", profileName) + return err +} + +func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error { + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + cfg, _, err := r.ConfigStore.LoadDefault() + if err != nil { + return err + } + + profileName := r.resolveProfileName(cfg.CurrentProfile) + profile, ok := cfg.Profiles[profileName] + if !ok { + return fmt.Errorf("profil %q absent, lance %s setup", profileName, r.BinaryName) + } + + token, err := r.readToken() + if err != nil { + if errors.Is(err, secretstore.ErrNotFound) { + return fmt.Errorf("secret %q introuvable, lance %s setup", r.SecretName, r.BinaryName) + } + return err + } + + fmt.Fprintf(stdout, "MCP prêt sur %s (profil %s).\n", profile.BaseURL, profileName) + fmt.Fprintf(stdout, "Token chargé (%d caractères).\n", len(strings.TrimSpace(token))) + fmt.Fprintln(stdout, "Ajoute ici ta logique métier MCP.") + return nil +} + +func (r Runtime) runConfigShow(_ context.Context, inv bootstrap.Invocation) error { + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + cfg, path, err := r.ConfigStore.LoadDefault() + if err != nil { + return err + } + + payload, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("encode config JSON: %w", err) + } + + if _, err := fmt.Fprintf(stdout, "Config: %s\n", path); err != nil { + return err + } + _, err = fmt.Fprintf(stdout, "%s\n", payload) + return err +} + +func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) error { + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + report := cli.RunDoctor(ctx, cli.DoctorOptions{ + ConfigCheck: cli.NewConfigCheck(r.ConfigStore), + SecretStoreCheck: cli.SecretStoreAvailabilityCheck(r.openSecretStore), + RequiredSecrets: []cli.DoctorSecret{ + {Name: r.SecretName, Label: "API token"}, + }, + SecretStoreFactory: r.openSecretStore, + ManifestDir: ".", + }) + + if err := cli.RenderDoctorReport(stdout, report); err != nil { + return err + } + + if report.HasFailures() { + return errors.New("doctor checks failed") + } + + return nil +} + +func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error { + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + return update.Run(ctx, update.Options{ + CurrentVersion: r.Version, + BinaryName: r.BinaryName, + ReleaseSource: r.Manifest.Update.ReleaseSource(), + Stdout: stdout, + }) +} + +func (r Runtime) openSecretStore() (secretstore.Store, error) { + return secretstore.Open(secretstore.Options{ + ServiceName: r.BinaryName, + BackendPolicy: secretstore.BackendPolicy(r.SecretStorePolicy), + LookupEnv: func(name string) (string, bool) { + if name == r.SecretName { + return os.LookupEnv(r.TokenEnv) + } + return os.LookupEnv(name) + }, + }) +} + +func (r Runtime) readToken() (string, error) { + store, err := r.openSecretStore() + if err != nil { + return "", err + } + + return store.GetSecret(r.SecretName) +} + +func (r Runtime) resolveProfileName(currentProfile string) string { + resolved := cli.ResolveProfileName("", os.Getenv(r.ProfileEnv), currentProfile) + if strings.TrimSpace(resolved) != "" { + return resolved + } + return r.DefaultProfile +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} +` + +const manifestTemplate = `binary_name = "{{.BinaryName}}" +docs_url = "{{.DocsURL}}" + +[update] +source_name = "Release endpoint" +driver = "{{.ReleaseDriver}}" +repository = "{{.ReleaseRepository}}" +base_url = "{{.ReleaseBaseURL}}" +asset_name_template = "{binary}-{os}-{arch}{ext}" +checksum_asset_name = "{asset}.sha256" +checksum_required = false +token_header = "Authorization" +token_prefix = "token" +token_env_names = ["{{.ReleaseTokenEnv}}"] + +[environment] +known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] + +[secret_store] +backend_policy = "{{.SecretStorePolicy}}" + +[profiles] +default = "{{.DefaultProfile}}" +known = [{{- range $index, $value := .Profiles}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] + +[bootstrap] +description = "{{.Description}}" +` + +const readmeTemplate = `# {{.BinaryName}} + +Binaire MCP généré depuis ` + "`mcp-framework`" + `. + +## Arborescence générée + +` + "```text" + ` +. +├── cmd/ +│ └── {{.BinaryName}}/ +│ └── main.go +├── internal/ +│ └── app/ +│ └── app.go +├── .gitignore +├── go.mod +├── mcp.toml +└── README.md +` + "```" + ` + +## Démarrage rapide + +1. Installer les dépendances : + +` + "```bash" + ` +go mod tidy +` + "```" + ` + +2. Vérifier l’aide CLI bootstrap : + +` + "```bash" + ` +go run ./cmd/{{.BinaryName}} help +` + "```" + ` + +3. Initialiser la configuration locale : + +` + "```bash" + ` +go run ./cmd/{{.BinaryName}} setup +` + "```" + ` + +4. Lancer le flux MCP (placeholder) : + +` + "```bash" + ` +go run ./cmd/{{.BinaryName}} mcp +` + "```" + ` + +5. Vérifier la configuration et le manifeste : + +` + "```bash" + ` +go run ./cmd/{{.BinaryName}} config test +` + "```" + ` + +## Points à adapter + +- Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs). +- Compléter la logique métier dans ` + "`internal/app/app.go`" + ` (` + "`runMCP`" + `). +- Ajuster les variables d’environnement connues si besoin. +` diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go new file mode 100644 index 0000000..3e65462 --- /dev/null +++ b/scaffold/scaffold_test.go @@ -0,0 +1,183 @@ +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", + "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", + "update.Run", + "manifest.LoadDefault", + "bootstrap.Run", + } { + 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]", + "[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", + "internal/app/app.go", + } { + if !strings.Contains(string(readme), snippet) { + t.Fatalf("README missing snippet %q", snippet) + } + } +} + +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) + } +} -- 2.45.2 From d42a790bc07c414baf6384c5e4ca1a1598e335cd Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 15:59:18 +0200 Subject: [PATCH 16/58] feat(cli): add scaffold init command --- README.md | 21 ++++ cmd/mcp-framework/main.go | 200 +++++++++++++++++++++++++++++++++ cmd/mcp-framework/main_test.go | 101 +++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 cmd/mcp-framework/main.go create mode 100644 cmd/mcp-framework/main_test.go diff --git a/README.md b/README.md index 72270a8..b3fb9b3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,27 @@ pas une application MCP complète. go get gitea.lclr.dev/AI/mcp-framework ``` +## CLI de scaffold + +Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go : + +```bash +go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest +mcp-framework scaffold init \ + --target ./my-mcp \ + --module example.com/my-mcp \ + --binary my-mcp \ + --profiles dev,prod +``` + +Puis dans le projet généré : + +```bash +cd my-mcp +go mod tidy +go run ./cmd/my-mcp help +``` + ## Packages - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go new file mode 100644 index 0000000..d6f98b9 --- /dev/null +++ b/cmd/mcp-framework/main.go @@ -0,0 +1,200 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold" +) + +const toolName = "mcp-framework" + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run(args []string, stdout, stderr io.Writer) error { + if stdout == nil { + stdout = io.Discard + } + if stderr == nil { + stderr = io.Discard + } + + if len(args) == 0 || isHelpArg(args[0]) { + printGlobalHelp(stdout) + return nil + } + + switch args[0] { + case "scaffold": + return runScaffold(args[1:], stdout, stderr) + default: + return fmt.Errorf("unknown command %q", args[0]) + } +} + +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 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.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, + Overwrite: overwrite, + }) + if err != nil { + return err + } + + if _, err := fmt.Fprintf(stdout, "Scaffold generated in %s\n", result.Root); err != nil { + return err + } + for _, file := range result.Files { + if _, err := fmt.Fprintf(stdout, "- %s\n", file); err != nil { + return err + } + } + + return nil +} + +func printGlobalHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", + toolName, + toolName, + ) +} + +func printScaffoldHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s scaffold init [flags]\n\nSubcommands:\n init Génère un nouveau squelette MCP\n", + toolName, + ) +} + +func printScaffoldInitHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s scaffold init --target [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --overwrite Écraser les fichiers existants\n", + toolName, + ) +} + +func shouldShowHelp(args []string) bool { + for _, arg := range args { + if isHelpArg(arg) { + return true + } + } + return false +} + +func isHelpArg(arg string) bool { + switch strings.TrimSpace(arg) { + case "-h", "--help", "help": + return true + default: + return false + } +} + +func parseCSV(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + result = append(result, trimmed) + } + return result +} diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go new file mode 100644 index 0000000..6f32c6f --- /dev/null +++ b/cmd/mcp-framework/main_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunPrintsGlobalHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + if err := run(nil, &stdout, &stderr); err != nil { + t.Fatalf("run returned error: %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "mcp-framework ") { + t.Fatalf("global help should mention command usage: %q", output) + } + if !strings.Contains(output, "scaffold init") { + t.Fatalf("global help should mention scaffold init: %q", output) + } +} + +func TestRunScaffoldInitCreatesProject(t *testing.T) { + target := filepath.Join(t.TempDir(), "demo-mcp") + args := []string{ + "scaffold", "init", + "--target", target, + "--module", "example.com/demo-mcp", + "--binary", "demo-mcp", + "--profiles", "dev,prod", + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + if err := run(args, &stdout, &stderr); err != nil { + t.Fatalf("run returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(target, "cmd", "demo-mcp", "main.go")); err != nil { + t.Fatalf("generated main.go missing: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "internal", "app", "app.go")); err != nil { + t.Fatalf("generated app.go missing: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "mcp.toml")); err != nil { + t.Fatalf("generated mcp.toml missing: %v", err) + } + + if !strings.Contains(stdout.String(), "Scaffold generated in") { + t.Fatalf("stdout should include generation summary: %q", stdout.String()) + } +} + +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) + } +} -- 2.45.2 From 8c4f88ea934f09b2d0734ff3b79cc7f04370e825 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 16:31:26 +0200 Subject: [PATCH 17/58] feat(bootstrap): add command alias expansion --- bootstrap/bootstrap.go | 61 +++++++++++++++++++++++++++- bootstrap/bootstrap_test.go | 79 +++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 3a14226..4d5521b 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -47,6 +47,7 @@ type Options struct { BinaryName string Description string Version string + Aliases map[string][]string Args []string Stdin io.Reader Stdout io.Writer @@ -113,7 +114,7 @@ func Run(ctx context.Context, opts Options) error { return ErrBinaryNameRequired } - command, commandArgs, showHelp := parseArgs(normalized.Args) + command, commandArgs, showHelp := parseArgs(expandAliases(normalized.Args, normalized.Aliases)) if showHelp { return printHelp(normalized, command, commandArgs) } @@ -168,6 +169,7 @@ func normalize(opts Options) Options { } func parseArgs(args []string) (command string, commandArgs []string, showHelp bool) { + args = trimArgs(args) if len(args) == 0 { return "", nil, false } @@ -193,6 +195,63 @@ func parseArgs(args []string) (command string, commandArgs []string, showHelp bo return command, commandArgs, false } +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 diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index d3d34c1..356c76f 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -319,3 +319,82 @@ func TestRunConfigReturnsUnknownSubcommand(t *testing.T) { t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err) } } + +func TestRunRoutesAliasToConfigSubcommandHook(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + var got Invocation + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Aliases: map[string][]string{ + "doctor": {CommandConfig, ConfigSubcommandTest}, + }, + Args: []string{"doctor", "--profile", "work"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + ConfigTest: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != "config test" { + t.Fatalf("invocation command = %q, want %q", got.Command, "config test") + } + wantArgs := []string{"--profile", "work"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } +} + +func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Aliases: map[string][]string{ + "doctor": {CommandConfig, ConfigSubcommandTest}, + }, + Args: []string{"help", "doctor"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp config test [args]") { + t.Fatalf("command help output = %q", text) + } +} + +func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Aliases: map[string][]string{ + "doctor": {CommandConfig, ConfigSubcommandTest}, + }, + Args: []string{"doctor", "--help"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp config test [args]") { + t.Fatalf("command help output = %q", text) + } +} -- 2.45.2 From 26238eb31bc9bc835bfe826c5747483ee3ac8f38 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 16:40:50 +0200 Subject: [PATCH 18/58] feat(secretstore): add runtime manifest helper for backend opening --- README.md | 21 ++++- scaffold/scaffold.go | 7 +- scaffold/scaffold_test.go | 2 +- secretstore/manifest_open.go | 80 +++++++++++++++++ secretstore/manifest_open_test.go | 139 ++++++++++++++++++++++++++++++ secretstore/store.go | 33 ++++--- secretstore/store_test.go | 3 + 7 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 secretstore/manifest_open.go create mode 100644 secretstore/manifest_open_test.go diff --git a/README.md b/README.md index b3fb9b3..db1fb4d 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ go run ./cmd/my-mcp help - `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`. - `manifest` : lecture de `mcp.toml` à la racine du projet, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. - `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, wiring de base et README de démarrage). -- `secretstore` : lecture/écriture de secrets dans le wallet natif. +- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. ## Utilisation type @@ -288,7 +288,20 @@ Backends keyring typiques : - Linux : Secret Service ou KWallet selon l'environnement - Windows : Credential Manager -Exemple : +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{ @@ -480,7 +493,7 @@ mais peut réutiliser les checks communs et ajouter ses propres hooks : report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ ConfigCheck: cli.NewConfigCheck(store), SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) { - return secretstore.Open(secretstore.Options{ + return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ ServiceName: "my-mcp", }) }), @@ -488,7 +501,7 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ {Name: "api-token", Label: "API token"}, }, SecretStoreFactory: func() (secretstore.Store, error) { - return secretstore.Open(secretstore.Options{ + return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ ServiceName: "my-mcp", }) }, diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 06b5da4..bd2c96e 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -377,7 +377,6 @@ type Runtime struct { ProfileEnv string TokenEnv string SecretName string - SecretStorePolicy string } func Run(ctx context.Context, args []string, version string) error { @@ -420,7 +419,6 @@ func NewRuntime(version string) (Runtime, error) { ProfileEnv: profileEnv, TokenEnv: tokenEnv, SecretName: binaryName + "-api-token", - SecretStorePolicy: firstNonEmpty(scaffoldInfo.SecretStorePolicy, "{{.SecretStorePolicy}}"), }, nil } @@ -609,9 +607,8 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error } func (r Runtime) openSecretStore() (secretstore.Store, error) { - return secretstore.Open(secretstore.Options{ - ServiceName: r.BinaryName, - BackendPolicy: secretstore.BackendPolicy(r.SecretStorePolicy), + return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + ServiceName: r.BinaryName, LookupEnv: func(name string) (string, bool) { if name == r.SecretName { return os.LookupEnv(r.TokenEnv) diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 3e65462..c166a3b 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -65,7 +65,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { } for _, snippet := range []string{ "config.NewStore[Profile]", - "secretstore.Open", + "secretstore.OpenFromManifest", "update.Run", "manifest.LoadDefault", "bootstrap.Run", diff --git a/secretstore/manifest_open.go b/secretstore/manifest_open.go new file mode 100644 index 0000000..71e0390 --- /dev/null +++ b/secretstore/manifest_open.go @@ -0,0 +1,80 @@ +package secretstore + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +type ManifestLoader func(startDir string) (manifest.File, string, error) + +type ExecutableResolver func() (string, error) + +type OpenFromManifestOptions struct { + ServiceName string + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + ManifestLoader ManifestLoader + ExecutableResolver ExecutableResolver +} + +func OpenFromManifest(options OpenFromManifestOptions) (Store, error) { + policy, err := resolveManifestBackendPolicy(options) + if err != nil { + return nil, err + } + + return Open(Options{ + ServiceName: options.ServiceName, + BackendPolicy: policy, + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + }) +} + +func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolicy, 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 "", 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 BackendAuto, nil + } + return "", fmt.Errorf("load runtime manifest from %q: %w", startDir, err) + } + + if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" { + return BackendAuto, nil + } + + policy, err := normalizeBackendPolicy(BackendPolicy(file.SecretStore.BackendPolicy)) + if err != nil { + return "", fmt.Errorf("invalid secret_store.backend_policy in manifest %q: %w", strings.TrimSpace(manifestPath), err) + } + + return policy, nil +} diff --git a/secretstore/manifest_open_test.go b/secretstore/manifest_open_test.go new file mode 100644 index 0000000..bfe783a --- /dev/null +++ b/secretstore/manifest_open_test.go @@ -0,0 +1,139 @@ +package secretstore + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/99designs/keyring" + + "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) { + var gotStartDir string + + store, err := OpenFromManifest(OpenFromManifestOptions{ + ServiceName: "email-mcp", + LookupEnv: func(name string) (string, bool) { + if name == "EMAIL_TOKEN" { + return "from-env", true + } + return "", false + }, + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + gotStartDir = startDir + return manifest.File{ + SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)}, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("OpenFromManifest returned error: %v", err) + } + + wantDir := filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin") + if gotStartDir != wantDir { + t.Fatalf("manifest loader startDir = %q, want %q", gotStartDir, wantDir) + } + + value, err := store.GetSecret("EMAIL_TOKEN") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "from-env" { + t.Fatalf("GetSecret = %q, want from-env", value) + } +} + +func TestOpenFromManifestFallsBackToAutoWhenManifestIsMissing(t *testing.T) { + withKeyringHooks(t, nil, func(cfg keyring.Config) (keyring.Keyring, error) { + t.Fatal("unexpected keyring open call") + return nil, nil + }) + + store, err := OpenFromManifest(OpenFromManifestOptions{ + ServiceName: "email-mcp", + LookupEnv: func(name string) (string, bool) { + if name == "EMAIL_TOKEN" { + return "env-token", true + } + return "", false + }, + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(string) (manifest.File, string, error) { + return manifest.File{}, "", os.ErrNotExist + }, + }) + if err != nil { + t.Fatalf("OpenFromManifest returned error: %v", err) + } + + value, err := store.GetSecret("EMAIL_TOKEN") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "env-token" { + t.Fatalf("GetSecret = %q, want env-token", value) + } +} + +func TestOpenFromManifestReturnsExplicitErrorForInvalidManifestPolicy(t *testing.T) { + _, err := OpenFromManifest(OpenFromManifestOptions{ + ServiceName: "email-mcp", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{BackendPolicy: "totally-invalid"}, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrInvalidBackendPolicy) { + t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err) + } + if !strings.Contains(err.Error(), "secret_store.backend_policy") { + t.Fatalf("error = %v, want manifest policy context", err) + } + if !strings.Contains(err.Error(), manifest.DefaultFile) { + t.Fatalf("error = %v, want manifest path", err) + } +} + +func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) { + execErr := errors.New("boom") + _, err := OpenFromManifest(OpenFromManifestOptions{ + ExecutableResolver: func() (string, error) { + return "", execErr + }, + }) + if !errors.Is(err, execErr) { + t.Fatalf("error = %v, want wrapped executable resolver error", err) + } +} + +func TestOpenFromManifestReturnsManifestLoaderError(t *testing.T) { + loadErr := errors.New("cannot parse manifest") + _, err := OpenFromManifest(OpenFromManifestOptions{ + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(string) (manifest.File, string, error) { + return manifest.File{}, "", loadErr + }, + }) + if !errors.Is(err, loadErr) { + t.Fatalf("error = %v, want wrapped manifest loader error", err) + } +} diff --git a/secretstore/store.go b/secretstore/store.go index 3cb7e6f..1cd61d1 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -15,6 +15,7 @@ import ( var ErrNotFound = errors.New("secret not found") var ErrBackendUnavailable = errors.New("secret backend unavailable") var ErrReadOnly = errors.New("secret backend is read-only") +var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy") type BackendPolicy string @@ -77,15 +78,9 @@ var ( ) func Open(options Options) (Store, error) { - policy := options.BackendPolicy - if policy == "" { - policy = BackendAuto - } - - switch policy { - case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly: - default: - return nil, fmt.Errorf("invalid secret backend policy %q", policy) + policy, err := normalizeBackendPolicy(options.BackendPolicy) + if err != nil { + return nil, err } if policy == BackendEnvOnly { @@ -249,7 +244,7 @@ func allowedBackends(policy BackendPolicy, available []keyring.BackendType) ([]k } return []keyring.BackendType{keyring.KWalletBackend}, nil default: - return nil, fmt.Errorf("invalid secret backend policy %q", policy) + return nil, invalidBackendPolicyError(policy) } } @@ -260,3 +255,21 @@ func backendNames(backends []keyring.BackendType) []string { } return names } + +func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) { + trimmed := BackendPolicy(strings.TrimSpace(string(policy))) + if trimmed == "" { + return BackendAuto, nil + } + + switch trimmed { + case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly: + return trimmed, nil + default: + return "", invalidBackendPolicyError(trimmed) + } +} + +func invalidBackendPolicyError(policy BackendPolicy) error { + return fmt.Errorf("%w %q", ErrInvalidBackendPolicy, policy) +} diff --git a/secretstore/store_test.go b/secretstore/store_test.go index c7a2f68..818ea41 100644 --- a/secretstore/store_test.go +++ b/secretstore/store_test.go @@ -80,6 +80,9 @@ func TestOpenRejectsInvalidPolicy(t *testing.T) { if err == nil { t.Fatal("expected error") } + if !errors.Is(err, ErrInvalidBackendPolicy) { + t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err) + } } func TestOpenAutoUsesAvailableKeyringBackends(t *testing.T) { -- 2.45.2 From ea1768982eb576081047b9204a33763dbb985105 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 16:48:26 +0200 Subject: [PATCH 19/58] feat(cli): add multi-source resolve lookup helper --- README.md | 36 +++------ cli/resolve_lookup.go | 87 ++++++++++++++++++++++ cli/resolve_lookup_test.go | 145 +++++++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 24 deletions(-) create mode 100644 cli/resolve_lookup.go create mode 100644 cli/resolve_lookup_test.go diff --git a/README.md b/README.md index db1fb4d..942c8cd 100644 --- a/README.md +++ b/README.md @@ -439,32 +439,20 @@ Les validations sont appliquées de manière cohérente en TTY et en stdin non i Pour standardiser la résolution `flag > env > config > secret` avec provenance : ```go -lookup := func(source cli.ValueSource, key string) (string, bool, error) { - switch source { - case cli.SourceFlag: - value, ok := flagValues[key] - return value, ok, nil - case cli.SourceEnv: - value, ok := os.LookupEnv(key) - return value, ok, nil - case cli.SourceConfig: - value, ok := configValues[key] - return value, ok, nil - case cli.SourceSecret: - 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 - } - default: - return "", false, nil - } +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), // ErrNotFound => valeur absente, pas une erreur +}) + resolution, err := cli.ResolveFields(cli.ResolveOptions{ Fields: []cli.FieldSpec{ {Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"}, diff --git a/cli/resolve_lookup.go b/cli/resolve_lookup.go new file mode 100644 index 0000000..f42fc45 --- /dev/null +++ b/cli/resolve_lookup.go @@ -0,0 +1,87 @@ +package cli + +import ( + "errors" + "os" + + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type KeyLookupFunc func(key string) (string, bool, error) + +type ResolveLookupOptions struct { + Flag KeyLookupFunc + Env KeyLookupFunc + Config KeyLookupFunc + Secret KeyLookupFunc +} + +func ResolveLookup(options ResolveLookupOptions) LookupFunc { + return func(source ValueSource, key string) (string, bool, error) { + switch source { + case SourceFlag: + return runKeyLookup(options.Flag, key) + case SourceEnv: + return runKeyLookup(options.Env, key) + case SourceConfig: + return runKeyLookup(options.Config, key) + case SourceSecret: + return runKeyLookup(options.Secret, key) + case SourceDefault: + return "", false, nil + default: + return "", false, nil + } + } +} + +func runKeyLookup(lookup KeyLookupFunc, key string) (string, bool, error) { + if lookup == nil { + return "", false, nil + } + if key == "" { + return "", false, nil + } + return lookup(key) +} + +func MapLookup(values map[string]string) KeyLookupFunc { + return func(key string) (string, bool, error) { + value, ok := values[key] + return value, ok, nil + } +} + +func EnvLookup(lookup func(string) (string, bool)) KeyLookupFunc { + reader := lookup + if reader == nil { + reader = os.LookupEnv + } + + return func(key string) (string, bool, error) { + value, ok := reader(key) + return value, ok, nil + } +} + +func ConfigMap(values map[string]string) KeyLookupFunc { + return MapLookup(values) +} + +func SecretStore(store secretstore.Store) KeyLookupFunc { + if store == nil { + return nil + } + + return func(key string) (string, bool, error) { + value, err := store.GetSecret(key) + switch { + case err == nil: + return value, true, nil + case errors.Is(err, secretstore.ErrNotFound): + return "", false, nil + default: + return "", false, err + } + } +} diff --git a/cli/resolve_lookup_test.go b/cli/resolve_lookup_test.go new file mode 100644 index 0000000..32e4d23 --- /dev/null +++ b/cli/resolve_lookup_test.go @@ -0,0 +1,145 @@ +package cli + +import ( + "errors" + "testing" + + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type testSecretStore struct { + values map[string]string + errs map[string]error +} + +func (s testSecretStore) SetSecret(name, label, secret string) error { + return nil +} + +func (s testSecretStore) GetSecret(name string) (string, error) { + if err, ok := s.errs[name]; ok { + return "", err + } + value, ok := s.values[name] + if !ok { + return "", secretstore.ErrNotFound + } + return value, nil +} + +func (s testSecretStore) DeleteSecret(name string) error { + return nil +} + +func TestResolveLookupWithStandardProviders(t *testing.T) { + t.Setenv("MCP_PASSWORD", "") + + lookup := ResolveLookup(ResolveLookupOptions{ + Flag: MapLookup(map[string]string{"host": "https://flag.example.com"}), + Env: EnvLookup(nil), + Config: ConfigMap(map[string]string{"username": "config-user"}), + Secret: SecretStore(testSecretStore{ + values: map[string]string{"smtp-password": "secret-password"}, + }), + }) + + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "host", + Required: true, + FlagKey: "host", + }, + { + Name: "username", + Required: true, + EnvKey: "MCP_USERNAME", + ConfigKey: "username", + }, + { + Name: "password", + Required: true, + EnvKey: "MCP_PASSWORD", + SecretKey: "smtp-password", + }, + }, + Lookup: lookup, + }) + if err != nil { + t.Fatalf("ResolveFields returned error: %v", err) + } + + host, _ := resolution.Get("host") + if host.Source != SourceFlag || host.Value != "https://flag.example.com" { + t.Fatalf("host = %+v", host) + } + + username, _ := resolution.Get("username") + if username.Source != SourceConfig || username.Value != "config-user" { + t.Fatalf("username = %+v", username) + } + + password, _ := resolution.Get("password") + if password.Source != SourceSecret || password.Value != "secret-password" { + t.Fatalf("password = %+v", password) + } +} + +func TestSecretStoreProviderTreatsErrNotFoundAsMissing(t *testing.T) { + lookup := ResolveLookup(ResolveLookupOptions{ + Secret: SecretStore(testSecretStore{ + errs: map[string]error{"smtp-password": secretstore.ErrNotFound}, + }), + }) + + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "password", + Required: true, + SecretKey: "smtp-password", + DefaultValue: "fallback-password", + }, + }, + Lookup: lookup, + }) + if err != nil { + t.Fatalf("ResolveFields returned error: %v", err) + } + + password, _ := resolution.Get("password") + if password.Source != SourceDefault || password.Value != "fallback-password" { + t.Fatalf("password = %+v", password) + } +} + +func TestSecretStoreProviderPropagatesBackendErrors(t *testing.T) { + backendErr := errors.New("backend unavailable") + lookup := ResolveLookup(ResolveLookupOptions{ + Secret: SecretStore(testSecretStore{ + errs: map[string]error{"smtp-password": backendErr}, + }), + }) + + _, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "password", + Required: true, + SecretKey: "smtp-password", + }, + }, + Lookup: lookup, + }) + + var sourceErr *SourceLookupError + if !errors.As(err, &sourceErr) { + t.Fatalf("ResolveFields error = %v, want SourceLookupError", err) + } + if sourceErr.Source != SourceSecret { + t.Fatalf("sourceErr.Source = %q, want %q", sourceErr.Source, SourceSecret) + } + if !errors.Is(err, backendErr) { + t.Fatalf("ResolveFields error should wrap backend error") + } +} -- 2.45.2 From 3d8a7dc84dc5a0bbda46883a4d1a8a0a6f6a400e Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 16:53:26 +0200 Subject: [PATCH 20/58] feat(cli): add reusable doctor check for resolved profile fields --- README.md | 30 ++++++++++++ cli/doctor.go | 114 +++++++++++++++++++++++++++++++++++++++++++++ cli/doctor_test.go | 112 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+) diff --git a/README.md b/README.md index 942c8cd..a713318 100644 --- a/README.md +++ b/README.md @@ -520,6 +520,36 @@ if report.HasFailures() { } ``` +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), // provenance explicite: env ou secret + }), + }), + }, +}) +``` + +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. + ## Auto-Update Le package `update` supporte les drivers `gitea`, `gitlab` et `github`. diff --git a/cli/doctor.go b/cli/doctor.go index ae2cdd9..8dfca94 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -270,6 +270,82 @@ func RequiredSecretsCheck(factory func() (secretstore.Store, error), required [] } } +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) @@ -334,3 +410,41 @@ func doctorLabel(status DoctorStatus) string { return "FAIL" } } + +func requiredFieldNames(specs []FieldSpec) []string { + required := make([]string, 0, len(specs)) + seen := make(map[string]struct{}, len(specs)) + + for _, spec := range specs { + if !spec.Required { + continue + } + + name := strings.TrimSpace(spec.Name) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + + seen[name] = struct{}{} + required = append(required, name) + } + + return required +} + +func resolveFieldsErrorSummary(err error) string { + var missing *MissingRequiredValuesError + if errors.As(err, &missing) { + return "required profile values are missing" + } + + var lookupErr *SourceLookupError + if errors.As(err, &lookupErr) { + return fmt.Sprintf("cannot resolve profile value %q", lookupErr.Field) + } + + return "profile resolution failed" +} diff --git a/cli/doctor_test.go b/cli/doctor_test.go index 61ac464..04dc23f 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -171,3 +171,115 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) { t.Fatalf("detail = %q", result.Detail) } } + +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) + } +} -- 2.45.2 From 0e5bfb2d390143ec86810667c1e1169a733abeb5 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 17:51:28 +0200 Subject: [PATCH 21/58] feat(bootstrap): expose doctor alias in help and options --- README.md | 11 ++-- bootstrap/bootstrap.go | 103 ++++++++++++++++++++++++++++++++---- bootstrap/bootstrap_test.go | 84 +++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a713318..f9145a8 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,13 @@ Exemple minimal : ```go func main() { err := bootstrap.Run(context.Background(), bootstrap.Options{ - BinaryName: "my-mcp", - Description: "Client MCP", - Version: version, + BinaryName: "my-mcp", + Description: "Client MCP", + Version: version, + EnableDoctorAlias: true, // expose `doctor` comme alias de `config test` + AliasDescriptions: map[string]string{ + "doctor": "Diagnostiquer la configuration locale.", + }, Hooks: bootstrap.Hooks{ Setup: func(ctx context.Context, inv bootstrap.Invocation) error { return runSetup(ctx, inv.Args) @@ -102,6 +106,7 @@ automatiquement `Options.Version`. Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). +Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. ## Manifeste `mcp.toml` diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 4d5521b..ea2ff2f 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "sort" "strings" ) @@ -15,6 +16,7 @@ const ( CommandConfig = "config" CommandUpdate = "update" CommandVersion = "version" + CommandDoctor = "doctor" ConfigSubcommandShow = "show" ConfigSubcommandTest = "test" @@ -44,15 +46,17 @@ type Hooks struct { } type Options struct { - BinaryName string - Description string - Version string - Aliases map[string][]string - Args []string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - Hooks Hooks + BinaryName string + Description string + Version string + Aliases map[string][]string + AliasDescriptions map[string]string + EnableDoctorAlias bool + Args []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Hooks Hooks } type Invocation struct { @@ -165,9 +169,62 @@ func normalize(opts Options) Options { if opts.Args == nil { opts.Args = os.Args[1:] } + opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias) + opts.AliasDescriptions = normalizeAliasDescriptions(opts.AliasDescriptions, opts.Aliases, opts.EnableDoctorAlias) return opts } +func normalizeAliases(aliases map[string][]string, enableDoctorAlias bool) map[string][]string { + normalized := make(map[string][]string, len(aliases)+1) + for name, target := range aliases { + trimmedName := strings.TrimSpace(name) + trimmedTarget := trimArgs(target) + if trimmedName == "" || len(trimmedTarget) == 0 { + continue + } + normalized[trimmedName] = trimmedTarget + } + + if enableDoctorAlias { + if _, ok := normalized[CommandDoctor]; !ok { + normalized[CommandDoctor] = []string{CommandConfig, ConfigSubcommandTest} + } + } + + if len(normalized) == 0 { + return nil + } + return normalized +} + +func normalizeAliasDescriptions(descriptions map[string]string, aliases map[string][]string, enableDoctorAlias bool) map[string]string { + normalized := make(map[string]string, len(descriptions)+1) + for name, description := range descriptions { + trimmedName := strings.TrimSpace(name) + trimmedDescription := strings.TrimSpace(description) + if trimmedName == "" || trimmedDescription == "" { + continue + } + if _, ok := aliases[trimmedName]; !ok { + continue + } + normalized[trimmedName] = trimmedDescription + } + + if enableDoctorAlias { + if _, ok := aliases[CommandDoctor]; ok { + if _, defined := normalized[CommandDoctor]; !defined { + normalized[CommandDoctor] = "Diagnostiquer la configuration locale." + } + } + } + + if len(normalized) == 0 { + return nil + } + return normalized +} + func parseArgs(args []string) (command string, commandArgs []string, showHelp bool) { args = trimArgs(args) if len(args) == 0 { @@ -435,6 +492,34 @@ func printGlobalHelp(opts Options) error { } } + 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 + } + } + } + _, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help \n", opts.BinaryName) return err } + +func aliasDescription(descriptions map[string]string, name, target string) string { + description := strings.TrimSpace(descriptions[name]) + if description == "" { + return fmt.Sprintf("Alias de %q.", target) + } + return fmt.Sprintf("%s (alias de %q).", description, target) +} diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index 356c76f..02644e3 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -398,3 +398,87 @@ func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) { 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) + } +} -- 2.45.2 From 845d20541b5f667ed7305d5045e025fd9e4b8af8 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 18:02:07 +0200 Subject: [PATCH 22/58] feat: add unified build command driven by mcp.toml --- README.md | 34 +++++ cmd/mcp-framework/build.go | 234 ++++++++++++++++++++++++++++++++ cmd/mcp-framework/build_test.go | 155 +++++++++++++++++++++ cmd/mcp-framework/main.go | 4 +- cmd/mcp-framework/main_test.go | 3 + manifest/manifest.go | 14 ++ manifest/manifest_test.go | 14 ++ scaffold/scaffold.go | 5 + scaffold/scaffold_test.go | 2 + 9 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 cmd/mcp-framework/build.go create mode 100644 cmd/mcp-framework/build_test.go diff --git a/README.md b/README.md index f9145a8..18502d8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,36 @@ go mod tidy go run ./cmd/my-mcp help ``` +## CLI de build unifiée + +Le binaire `mcp-framework` expose aussi une commande de build standardisée pour +les projets consommateurs : + +```bash +go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build +``` + +Par défaut la commande : + +- lit `mcp.toml` (en remontant les répertoires parents) +- récupère `binary_name` +- build `./cmd/` +- produit `build/--{.exe}` +- injecte la version via `-X main.version=` + (`VERSION` env, sinon `git describe`, sinon `dev`) + +Options principales : + +- `--manifest-dir` +- `--binary` +- `--package` +- `--build-dir` +- `--goos` +- `--goarch` +- `--version` +- `--version-var` +- `--ldflag` (répétable) + ## Packages - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. @@ -150,6 +180,7 @@ 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`. +- `[build]` : paramètres de build pour `mcp-framework build`. - `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. @@ -162,6 +193,9 @@ Champs supportés : - `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. +- `[build].main_package` : package main à builder (ex: `./cmd/my-mcp`). +- `[build].output_dir` : répertoire de sortie des artefacts (défaut `build`). +- `[build].version_var` : variable Go ciblée par `-X` pour injecter la version (`main.version`, `gitlab.../internal/app.Version`, etc.). - `[environment].known` : variables d'environnement connues du projet. - `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`). - `[profiles].default` : profil recommandé par défaut. diff --git a/cmd/mcp-framework/build.go b/cmd/mcp-framework/build.go new file mode 100644 index 0000000..de70f8d --- /dev/null +++ b/cmd/mcp-framework/build.go @@ -0,0 +1,234 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + manifestpkg "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +type stringListFlag []string + +func (f *stringListFlag) String() string { + return strings.Join(*f, ",") +} + +func (f *stringListFlag) Set(value string) error { + *f = append(*f, strings.TrimSpace(value)) + return nil +} + +func runBuild(args []string, stdout, stderr io.Writer) error { + if shouldShowHelp(args) { + printBuildHelp(stdout) + return nil + } + + fs := flag.NewFlagSet("build", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + defaultGOOS := strings.TrimSpace(os.Getenv("GOOS")) + if defaultGOOS == "" { + defaultGOOS = runtime.GOOS + } + + defaultGOARCH := strings.TrimSpace(os.Getenv("GOARCH")) + if defaultGOARCH == "" { + defaultGOARCH = runtime.GOARCH + } + + var manifestDir string + var binaryName string + var mainPackage string + var buildDir string + var goos string + var goarch string + var version string + var versionVar string + var gocache string + var extraLDFlags stringListFlag + + fs.StringVar(&manifestDir, "manifest-dir", ".", "Répertoire de départ pour trouver mcp.toml") + fs.StringVar(&binaryName, "binary", "", "Nom du binaire (override binary_name)") + fs.StringVar(&mainPackage, "package", "", "Package main à builder (override [build].main_package)") + fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") + fs.StringVar(&goos, "goos", defaultGOOS, "GOOS cible") + fs.StringVar(&goarch, "goarch", defaultGOARCH, "GOARCH cible") + fs.StringVar(&version, "version", "", "Version injectée (par défaut VERSION env, puis git describe, puis dev)") + fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") + fs.StringVar(&gocache, "gocache", strings.TrimSpace(os.Getenv("GOCACHE")), "Valeur GOCACHE pour go build") + fs.Var(&extraLDFlags, "ldflag", "Option additionnelle passée à -ldflags (répéter si nécessaire)") + + if err := fs.Parse(args); err != nil { + _ = stderr + return fmt.Errorf("parse build flags: %w", err) + } + + if fs.NArg() > 0 { + return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) + } + + file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) + if err != nil { + return err + } + + projectDir := filepath.Dir(manifestPath) + + binaryName = firstNonEmpty(binaryName, file.BinaryName) + if binaryName == "" { + return errors.New("binary name is required (set binary_name in mcp.toml or --binary)") + } + + mainPackage = firstNonEmpty(mainPackage, file.Build.MainPackage) + if mainPackage == "" { + mainPackage = fmt.Sprintf("./cmd/%s", binaryName) + } + + buildDir = firstNonEmpty(buildDir, file.Build.OutputDir, "build") + goos = firstNonEmpty(goos, runtime.GOOS) + goarch = firstNonEmpty(goarch, runtime.GOARCH) + version = resolveBuildVersion(version, projectDir) + versionVar = firstNonEmpty(versionVar, file.Build.VersionVar, "main.version") + + outputPath, err := buildOutputPath(projectDir, buildDir, binaryName, goos, goarch) + if err != nil { + return err + } + + outputDir := filepath.Dir(outputPath) + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("create build directory %q: %w", outputDir, err) + } + + ldflags := normalizeStringList(extraLDFlags) + if versionVar != "-" { + versionVar = strings.TrimSpace(versionVar) + if versionVar != "" { + ldflags = append([]string{fmt.Sprintf("-X %s=%s", versionVar, version)}, ldflags...) + } + } + + cmdArgs := []string{"build"} + if len(ldflags) > 0 { + cmdArgs = append(cmdArgs, "-ldflags", strings.Join(ldflags, " ")) + } + cmdArgs = append(cmdArgs, "-o", outputPath, mainPackage) + + cmd := exec.Command("go", cmdArgs...) + cmd.Dir = projectDir + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Env = withEnvOverrides(os.Environ(), map[string]string{ + "GOOS": goos, + "GOARCH": goarch, + "GOCACHE": strings.TrimSpace(gocache), + }) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("go build failed: %w", err) + } + + if _, err := fmt.Fprintf(stdout, "Build artifact: %s\n", outputPath); err != nil { + return err + } + + return nil +} + +func printBuildHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s build [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n", + toolName, + ) +} + +func resolveBuildVersion(explicit, projectDir string) string { + if value := strings.TrimSpace(explicit); value != "" { + return value + } + if value := strings.TrimSpace(os.Getenv("VERSION")); value != "" { + return value + } + + describe := exec.Command("git", "describe", "--tags", "--always", "--dirty") + describe.Dir = projectDir + out, err := describe.Output() + if err == nil { + if value := strings.TrimSpace(string(out)); value != "" { + return value + } + } + + return "dev" +} + +func buildOutputPath(projectDir, buildDir, binaryName, goos, goarch string) (string, error) { + artifactName := fmt.Sprintf("%s-%s-%s", binaryName, goos, goarch) + if goos == "windows" { + artifactName += ".exe" + } + + dir := strings.TrimSpace(buildDir) + if dir == "" { + dir = "build" + } + + if filepath.IsAbs(dir) { + return filepath.Join(dir, artifactName), nil + } + if strings.TrimSpace(projectDir) == "" { + return "", errors.New("project directory is required") + } + return filepath.Join(projectDir, dir, artifactName), nil +} + +func withEnvOverrides(base []string, overrides map[string]string) []string { + result := make([]string, 0, len(base)+len(overrides)) + for _, entry := range base { + key, _, found := strings.Cut(entry, "=") + if !found { + continue + } + if _, overridden := overrides[key]; overridden { + continue + } + result = append(result, entry) + } + + for key, value := range overrides { + if strings.TrimSpace(value) == "" { + continue + } + result = append(result, key+"="+value) + } + + return result +} + +func normalizeStringList(values []string) []string { + normalized := make([]string, 0, len(values)) + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + normalized = append(normalized, trimmed) + } + } + return normalized +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/cmd/mcp-framework/build_test.go b/cmd/mcp-framework/build_test.go new file mode 100644 index 0000000..5fa1fd4 --- /dev/null +++ b/cmd/mcp-framework/build_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestRunBuildBuildsArtifactFromManifest(t *testing.T) { + projectDir := t.TempDir() + writeBuildFixture(t, projectDir, "demo-mcp", ` +module example.com/demo + +go 1.25.0 +`, ` +binary_name = "demo-mcp" + +[build] +main_package = "./cmd/demo-mcp" +output_dir = "build" +version_var = "main.version" +`, ` +package main + +import "fmt" + +var version = "dev" + +func main() { + fmt.Print(version) +} +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run([]string{ + "build", + "--manifest-dir", projectDir, + "--goos", runtime.GOOS, + "--goarch", runtime.GOARCH, + "--version", "1.2.3", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) + } + + artifactPath := artifactPath(projectDir, "build", "demo-mcp", runtime.GOOS, runtime.GOARCH) + if _, err := os.Stat(artifactPath); err != nil { + t.Fatalf("expected artifact at %s: %v", artifactPath, err) + } + + output, err := exec.Command(artifactPath).Output() + if err != nil { + t.Fatalf("run artifact: %v", err) + } + if strings.TrimSpace(string(output)) != "1.2.3" { + t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "1.2.3") + } +} + +func TestRunBuildUsesManifestVersionVar(t *testing.T) { + projectDir := t.TempDir() + writeBuildFixture(t, projectDir, "custom-version", ` +module example.com/custom-version + +go 1.25.0 +`, ` +binary_name = "custom-version" + +[build] +main_package = "./cmd/custom-version" +output_dir = "dist" +version_var = "main.releaseVersion" +`, ` +package main + +import "fmt" + +var releaseVersion = "dev" + +func main() { + fmt.Print(releaseVersion) +} +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run([]string{ + "build", + "--manifest-dir", projectDir, + "--goos", runtime.GOOS, + "--goarch", runtime.GOARCH, + "--version", "9.9.9", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) + } + + artifactPath := artifactPath(projectDir, "dist", "custom-version", runtime.GOOS, runtime.GOARCH) + output, err := exec.Command(artifactPath).Output() + if err != nil { + t.Fatalf("run artifact: %v", err) + } + if strings.TrimSpace(string(output)) != "9.9.9" { + t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "9.9.9") + } +} + +func TestBuildHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + if err := run([]string{"build", "--help"}, &stdout, &stderr); err != nil { + t.Fatalf("run returned error: %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "--manifest-dir") { + t.Fatalf("build help should mention --manifest-dir: %q", output) + } + if !strings.Contains(output, "version_var") { + t.Fatalf("build help should mention manifest build config: %q", output) + } +} + +func writeBuildFixture(t *testing.T, projectDir, binaryName, goModContent, manifestContent, mainContent string) { + t.Helper() + + if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(strings.TrimSpace(goModContent)+"\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(strings.TrimSpace(manifestContent)+"\n"), 0o644); err != nil { + t.Fatalf("write mcp.toml: %v", err) + } + + cmdDir := filepath.Join(projectDir, "cmd", binaryName) + if err := os.MkdirAll(cmdDir, 0o755); err != nil { + t.Fatalf("mkdir cmd dir: %v", err) + } + if err := os.WriteFile(filepath.Join(cmdDir, "main.go"), []byte(strings.TrimSpace(mainContent)+"\n"), 0o644); err != nil { + t.Fatalf("write main.go: %v", err) + } +} + +func artifactPath(projectDir, outDir, binaryName, goos, goarch string) string { + name := binaryName + "-" + goos + "-" + goarch + if goos == "windows" { + name += ".exe" + } + return filepath.Join(projectDir, outDir, name) +} diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go index d6f98b9..d380a8c 100644 --- a/cmd/mcp-framework/main.go +++ b/cmd/mcp-framework/main.go @@ -34,6 +34,8 @@ func run(args []string, stdout, stderr io.Writer) error { } switch args[0] { + case "build": + return runBuild(args[1:], stdout, stderr) case "scaffold": return runScaffold(args[1:], stdout, stderr) default: @@ -142,7 +144,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error { func printGlobalHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", + "Usage:\n %s [options]\n\nCommands:\n build Build un binaire MCP de manière standardisée\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", toolName, toolName, ) diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go index 6f32c6f..1476bf7 100644 --- a/cmd/mcp-framework/main_test.go +++ b/cmd/mcp-framework/main_test.go @@ -23,6 +23,9 @@ func TestRunPrintsGlobalHelp(t *testing.T) { if !strings.Contains(output, "scaffold init") { t.Fatalf("global help should mention scaffold init: %q", output) } + if !strings.Contains(output, "build") { + t.Fatalf("global help should mention build: %q", output) + } } func TestRunScaffoldInitCreatesProject(t *testing.T) { diff --git a/manifest/manifest.go b/manifest/manifest.go index b3c9414..b9238f3 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -18,6 +18,7 @@ type File struct { BinaryName string `toml:"binary_name"` DocsURL string `toml:"docs_url"` Update Update `toml:"update"` + Build Build `toml:"build"` Environment Environment `toml:"environment"` SecretStore SecretStore `toml:"secret_store"` Profiles Profiles `toml:"profiles"` @@ -38,6 +39,12 @@ type Update struct { TokenEnvNames []string `toml:"token_env_names"` } +type Build struct { + MainPackage string `toml:"main_package"` + OutputDir string `toml:"output_dir"` + VersionVar string `toml:"version_var"` +} + type Environment struct { Known []string `toml:"known"` } @@ -141,6 +148,7 @@ func (f *File) normalize() { f.BinaryName = strings.TrimSpace(f.BinaryName) f.DocsURL = strings.TrimSpace(f.DocsURL) f.Update.normalize() + f.Build.normalize() f.Environment.normalize() f.SecretStore.normalize() f.Profiles.normalize() @@ -160,6 +168,12 @@ func (u *Update) normalize() { u.TokenEnvNames = normalizeStringList(u.TokenEnvNames) } +func (b *Build) normalize() { + b.MainPackage = strings.TrimSpace(b.MainPackage) + b.OutputDir = strings.TrimSpace(b.OutputDir) + b.VersionVar = strings.TrimSpace(b.VersionVar) +} + func (e *Environment) normalize() { e.Known = normalizeStringList(e.Known) } diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 83ea7d5..6ccf1d6 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -151,6 +151,11 @@ docs_url = " https://docs.example.com/mcp " [update] latest_release_url = "https://example.com/latest" +[build] +main_package = " ./cmd/my-mcp " +output_dir = " build " +version_var = " main.version " + [environment] known = [" MCP_PROFILE ", "", "MCP_TOKEN"] @@ -183,6 +188,15 @@ description = " Client MCP interne " if !slices.Equal(file.Environment.Known, []string{"MCP_PROFILE", "MCP_TOKEN"}) { t.Fatalf("environment known = %v", file.Environment.Known) } + if file.Build.MainPackage != "./cmd/my-mcp" { + t.Fatalf("build main package = %q", file.Build.MainPackage) + } + if file.Build.OutputDir != "build" { + t.Fatalf("build output dir = %q", file.Build.OutputDir) + } + if file.Build.VersionVar != "main.version" { + t.Fatalf("build version var = %q", file.Build.VersionVar) + } if file.SecretStore.BackendPolicy != "auto" { t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy) } diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index bd2c96e..7a543f7 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -661,6 +661,11 @@ token_header = "Authorization" token_prefix = "token" token_env_names = ["{{.ReleaseTokenEnv}}"] +[build] +main_package = "./cmd/{{.BinaryName}}" +output_dir = "build" +version_var = "main.version" + [environment] known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index c166a3b..7425d65 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -85,9 +85,11 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "binary_name = \"my-mcp\"", "[update]", + "[build]", "[secret_store]", "[environment]", "[profiles]", + "version_var = \"main.version\"", "backend_policy = \"auto\"", } { if !strings.Contains(string(manifestContent), snippet) { -- 2.45.2 From f5e52463f260af95680cf86acc427e3918d138da Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 09:09:39 +0200 Subject: [PATCH 23/58] feat: add CI build matrix planning from manifest targets --- README.md | 67 +++++++ cmd/mcp-framework/build.go | 307 +++++++++++++++++++++++++++++--- cmd/mcp-framework/build_test.go | 106 +++++++++++ manifest/manifest.go | 40 ++++- manifest/manifest_test.go | 18 ++ scaffold/scaffold.go | 3 + scaffold/scaffold_test.go | 3 + 7 files changed, 512 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 18502d8..fb61c92 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ les projets consommateurs : go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build ``` +Et une commande de planification de matrice CI : + +```bash +go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build plan --format github +``` + Par défaut la commande : - lit `mcp.toml` (en remontant les répertoires parents) @@ -64,10 +70,59 @@ Options principales : - `--build-dir` - `--goos` - `--goarch` +- `--target` (`os/arch`, ex: `linux/amd64`) - `--version` - `--version-var` - `--ldflag` (répétable) +`build plan` options principales : + +- `--manifest-dir` +- `--binary` +- `--package` +- `--build-dir` +- `--version-var` +- `--format` (`json`, `github`, `gitlab`) + +Exemple GitHub Actions (matrix dynamique) : + +```yaml +jobs: + plan: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - id: plan + run: echo "matrix=$(mcp-framework build plan --format github | tr -d '\n')" >> "$GITHUB_OUTPUT" + + build: + needs: [plan] + strategy: + matrix: ${{ fromJson(needs.plan.outputs.matrix) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: mcp-framework build --target "${{ matrix.target }}" --version "${{ github.ref_name }}" + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: ${{ matrix.artifact_path }} +``` + +Exemple GitLab CI (snippet matrix généré) : + +```bash +mcp-framework build plan --format gitlab > matrix.yml +``` + +Puis inclure le snippet `parallel.matrix` dans le job de build et exécuter : + +```bash +mcp-framework build --target "${GOOS}/${GOARCH}" --version "${CI_COMMIT_TAG}" +``` + ## Packages - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. @@ -161,6 +216,17 @@ token_header = "Authorization" token_prefix = "token" token_env_names = ["GITEA_TOKEN"] +[build] +main_package = "./cmd/my-mcp" +output_dir = "build" +version_var = "main.version" +[[build.targets]] +os = "linux" +arch = "amd64" +[[build.targets]] +os = "darwin" +arch = "arm64" + [environment] known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"] @@ -196,6 +262,7 @@ Champs supportés : - `[build].main_package` : package main à builder (ex: `./cmd/my-mcp`). - `[build].output_dir` : répertoire de sortie des artefacts (défaut `build`). - `[build].version_var` : variable Go ciblée par `-X` pour injecter la version (`main.version`, `gitlab.../internal/app.Version`, etc.). +- `[[build.targets]]` : cibles de compilation (`os`, `arch`) exploitées par `mcp-framework build plan`. - `[environment].known` : variables d'environnement connues du projet. - `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`). - `[profiles].default` : profil recommandé par défaut. diff --git a/cmd/mcp-framework/build.go b/cmd/mcp-framework/build.go index de70f8d..1e62344 100644 --- a/cmd/mcp-framework/build.go +++ b/cmd/mcp-framework/build.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "errors" "flag" "fmt" @@ -16,6 +17,43 @@ import ( type stringListFlag []string +type buildTarget struct { + GOOS string + GOARCH string +} + +type buildConfig struct { + ProjectDir string + BinaryName string + MainPackage string + BuildDir string + VersionVar string + ManifestTargets []manifestpkg.BuildTarget +} + +type buildConfigOverrides struct { + BinaryName string + MainPackage string + BuildDir string + VersionVar string +} + +type buildPlanTarget struct { + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + Target string `json:"target"` + ArtifactName string `json:"artifact_name"` + ArtifactPath string `json:"artifact_path"` +} + +type buildPlanOutput struct { + BinaryName string `json:"binary_name"` + MainPackage string `json:"main_package"` + BuildDir string `json:"build_dir"` + VersionVar string `json:"version_var"` + Targets []buildPlanTarget `json:"targets"` +} + func (f *stringListFlag) String() string { return strings.Join(*f, ",") } @@ -26,6 +64,13 @@ func (f *stringListFlag) Set(value string) error { } func runBuild(args []string, stdout, stderr io.Writer) error { + if len(args) > 0 { + switch strings.TrimSpace(args[0]) { + case "plan": + return runBuildPlan(args[1:], stdout, stderr) + } + } + if shouldShowHelp(args) { printBuildHelp(stdout) return nil @@ -34,15 +79,8 @@ func runBuild(args []string, stdout, stderr io.Writer) error { fs := flag.NewFlagSet("build", flag.ContinueOnError) fs.SetOutput(io.Discard) - defaultGOOS := strings.TrimSpace(os.Getenv("GOOS")) - if defaultGOOS == "" { - defaultGOOS = runtime.GOOS - } - - defaultGOARCH := strings.TrimSpace(os.Getenv("GOARCH")) - if defaultGOARCH == "" { - defaultGOARCH = runtime.GOARCH - } + defaultGOOS := resolveDefaultGOOS() + defaultGOARCH := resolveDefaultGOARCH() var manifestDir string var binaryName string @@ -50,6 +88,7 @@ func runBuild(args []string, stdout, stderr io.Writer) error { var buildDir string var goos string var goarch string + var target string var version string var versionVar string var gocache string @@ -61,6 +100,7 @@ func runBuild(args []string, stdout, stderr io.Writer) error { fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") fs.StringVar(&goos, "goos", defaultGOOS, "GOOS cible") fs.StringVar(&goarch, "goarch", defaultGOARCH, "GOARCH cible") + fs.StringVar(&target, "target", "", "Cible au format os/arch (override --goos/--goarch)") fs.StringVar(&version, "version", "", "Version injectée (par défaut VERSION env, puis git describe, puis dev)") fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") fs.StringVar(&gocache, "gocache", strings.TrimSpace(os.Getenv("GOCACHE")), "Valeur GOCACHE pour go build") @@ -75,30 +115,31 @@ func runBuild(args []string, stdout, stderr io.Writer) error { return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) } - file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) + cfg, err := resolveBuildConfig(manifestDir, buildConfigOverrides{ + BinaryName: binaryName, + MainPackage: mainPackage, + BuildDir: buildDir, + VersionVar: versionVar, + }) if err != nil { return err } - projectDir := filepath.Dir(manifestPath) - - binaryName = firstNonEmpty(binaryName, file.BinaryName) - if binaryName == "" { - return errors.New("binary name is required (set binary_name in mcp.toml or --binary)") + if strings.TrimSpace(target) != "" { + parsedTarget, err := parseBuildTarget(target) + if err != nil { + return err + } + goos = parsedTarget.GOOS + goarch = parsedTarget.GOARCH } - mainPackage = firstNonEmpty(mainPackage, file.Build.MainPackage) - if mainPackage == "" { - mainPackage = fmt.Sprintf("./cmd/%s", binaryName) - } + goos = firstNonEmpty(goos, resolveDefaultGOOS()) + goarch = firstNonEmpty(goarch, resolveDefaultGOARCH()) + version = resolveBuildVersion(version, cfg.ProjectDir) + versionVar = cfg.VersionVar - buildDir = firstNonEmpty(buildDir, file.Build.OutputDir, "build") - goos = firstNonEmpty(goos, runtime.GOOS) - goarch = firstNonEmpty(goarch, runtime.GOARCH) - version = resolveBuildVersion(version, projectDir) - versionVar = firstNonEmpty(versionVar, file.Build.VersionVar, "main.version") - - outputPath, err := buildOutputPath(projectDir, buildDir, binaryName, goos, goarch) + outputPath, err := buildOutputPath(cfg.ProjectDir, cfg.BuildDir, cfg.BinaryName, goos, goarch) if err != nil { return err } @@ -120,10 +161,10 @@ func runBuild(args []string, stdout, stderr io.Writer) error { if len(ldflags) > 0 { cmdArgs = append(cmdArgs, "-ldflags", strings.Join(ldflags, " ")) } - cmdArgs = append(cmdArgs, "-o", outputPath, mainPackage) + cmdArgs = append(cmdArgs, "-o", outputPath, cfg.MainPackage) cmd := exec.Command("go", cmdArgs...) - cmd.Dir = projectDir + cmd.Dir = cfg.ProjectDir cmd.Stdout = stdout cmd.Stderr = stderr cmd.Env = withEnvOverrides(os.Environ(), map[string]string{ @@ -143,10 +184,210 @@ func runBuild(args []string, stdout, stderr io.Writer) error { return nil } +func runBuildPlan(args []string, stdout, stderr io.Writer) error { + if shouldShowHelp(args) { + printBuildPlanHelp(stdout) + return nil + } + + fs := flag.NewFlagSet("build plan", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var manifestDir string + var binaryName string + var mainPackage string + var buildDir string + var versionVar string + var format string + + fs.StringVar(&manifestDir, "manifest-dir", ".", "Répertoire de départ pour trouver mcp.toml") + fs.StringVar(&binaryName, "binary", "", "Nom du binaire (override binary_name)") + fs.StringVar(&mainPackage, "package", "", "Package main à builder (override [build].main_package)") + fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") + fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") + fs.StringVar(&format, "format", "json", "Format de sortie: json|github|gitlab") + + if err := fs.Parse(args); err != nil { + _ = stderr + return fmt.Errorf("parse build plan flags: %w", err) + } + + if fs.NArg() > 0 { + return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) + } + + cfg, err := resolveBuildConfig(manifestDir, buildConfigOverrides{ + BinaryName: binaryName, + MainPackage: mainPackage, + BuildDir: buildDir, + VersionVar: versionVar, + }) + if err != nil { + return err + } + + targets, err := resolveBuildTargets(cfg.ManifestTargets, resolveDefaultGOOS(), resolveDefaultGOARCH()) + if err != nil { + return err + } + + planTargets := make([]buildPlanTarget, 0, len(targets)) + for _, target := range targets { + outputPath, err := buildOutputPath(cfg.ProjectDir, cfg.BuildDir, cfg.BinaryName, target.GOOS, target.GOARCH) + if err != nil { + return err + } + + planTargets = append(planTargets, buildPlanTarget{ + GOOS: target.GOOS, + GOARCH: target.GOARCH, + Target: formatBuildTarget(target.GOOS, target.GOARCH), + ArtifactName: filepath.Base(outputPath), + ArtifactPath: formatArtifactPath(cfg.ProjectDir, outputPath), + }) + } + + switch strings.ToLower(strings.TrimSpace(format)) { + case "json": + return renderJSON(stdout, buildPlanOutput{ + BinaryName: cfg.BinaryName, + MainPackage: cfg.MainPackage, + BuildDir: cfg.BuildDir, + VersionVar: cfg.VersionVar, + Targets: planTargets, + }) + case "github": + include := make([]map[string]string, 0, len(planTargets)) + for _, target := range planTargets { + include = append(include, map[string]string{ + "goos": target.GOOS, + "goarch": target.GOARCH, + "target": target.Target, + "artifact_name": target.ArtifactName, + "artifact_path": target.ArtifactPath, + }) + } + return renderJSON(stdout, map[string]any{"include": include}) + case "gitlab": + return renderGitLabMatrix(stdout, planTargets) + default: + return fmt.Errorf("unsupported build plan format %q (expected json, github or gitlab)", format) + } +} + +func resolveBuildConfig(manifestDir string, overrides buildConfigOverrides) (buildConfig, error) { + file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) + if err != nil { + return buildConfig{}, err + } + + projectDir := filepath.Dir(manifestPath) + binaryName := firstNonEmpty(overrides.BinaryName, file.BinaryName) + if binaryName == "" { + return buildConfig{}, errors.New("binary name is required (set binary_name in mcp.toml or --binary)") + } + + mainPackage := firstNonEmpty(overrides.MainPackage, file.Build.MainPackage) + if mainPackage == "" { + mainPackage = fmt.Sprintf("./cmd/%s", binaryName) + } + + return buildConfig{ + ProjectDir: projectDir, + BinaryName: binaryName, + MainPackage: mainPackage, + BuildDir: firstNonEmpty(overrides.BuildDir, file.Build.OutputDir, "build"), + VersionVar: firstNonEmpty(overrides.VersionVar, file.Build.VersionVar, "main.version"), + ManifestTargets: append([]manifestpkg.BuildTarget(nil), file.Build.Targets...), + }, nil +} + +func resolveBuildTargets(manifestTargets []manifestpkg.BuildTarget, defaultGOOS, defaultGOARCH string) ([]buildTarget, error) { + if len(manifestTargets) == 0 { + defaultTarget, err := parseBuildTarget(formatBuildTarget(defaultGOOS, defaultGOARCH)) + if err != nil { + return nil, err + } + return []buildTarget{defaultTarget}, nil + } + + targets := make([]buildTarget, 0, len(manifestTargets)) + for _, manifestTarget := range manifestTargets { + target, err := parseBuildTarget(formatBuildTarget(manifestTarget.OS, manifestTarget.Arch)) + if err != nil { + return nil, err + } + targets = append(targets, target) + } + + return targets, nil +} + +func parseBuildTarget(value string) (buildTarget, error) { + raw := strings.TrimSpace(value) + parts := strings.Split(raw, "/") + if len(parts) != 2 { + return buildTarget{}, fmt.Errorf("invalid target %q (expected os/arch)", value) + } + + goos := strings.ToLower(strings.TrimSpace(parts[0])) + goarch := strings.ToLower(strings.TrimSpace(parts[1])) + if goos == "" || goarch == "" { + return buildTarget{}, fmt.Errorf("invalid target %q (expected non-empty os/arch)", value) + } + + return buildTarget{GOOS: goos, GOARCH: goarch}, nil +} + +func formatBuildTarget(goos, goarch string) string { + return strings.ToLower(strings.TrimSpace(goos)) + "/" + strings.ToLower(strings.TrimSpace(goarch)) +} + +func formatArtifactPath(projectDir, outputPath string) string { + rel, err := filepath.Rel(projectDir, outputPath) + if err == nil && rel != "" && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) { + return filepath.ToSlash(rel) + } + return filepath.ToSlash(outputPath) +} + +func renderJSON(w io.Writer, payload any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(payload) +} + +func renderGitLabMatrix(w io.Writer, targets []buildPlanTarget) error { + if _, err := fmt.Fprintln(w, "parallel:"); err != nil { + return err + } + if _, err := fmt.Fprintln(w, " matrix:"); err != nil { + return err + } + for _, target := range targets { + if _, err := fmt.Fprintf(w, " - GOOS: %q\n", target.GOOS); err != nil { + return err + } + if _, err := fmt.Fprintf(w, " GOARCH: %q\n", target.GOARCH); err != nil { + return err + } + } + return nil +} + func printBuildHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s build [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n", + "Usage:\n %s build [flags]\n %s build plan [flags]\n\nBuild flags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --target Cible os/arch (ex: linux/amd64), prioritaire sur --goos/--goarch\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nBuild plan flags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --version-var Override de [build].version_var\n --format json|github|gitlab\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n [[build.targets]]\n os = \"linux\"\n arch = \"amd64\"\n", + toolName, + toolName, + ) +} + +func printBuildPlanHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s build plan [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --version-var Override de [build].version_var\n --format json|github|gitlab (défaut: json)\n", toolName, ) } @@ -232,3 +473,11 @@ func firstNonEmpty(values ...string) string { } return "" } + +func resolveDefaultGOOS() string { + return firstNonEmpty(os.Getenv("GOOS"), runtime.GOOS) +} + +func resolveDefaultGOARCH() string { + return firstNonEmpty(os.Getenv("GOARCH"), runtime.GOARCH) +} diff --git a/cmd/mcp-framework/build_test.go b/cmd/mcp-framework/build_test.go index 5fa1fd4..85b9718 100644 --- a/cmd/mcp-framework/build_test.go +++ b/cmd/mcp-framework/build_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/json" "os" "os/exec" "path/filepath" @@ -110,6 +111,108 @@ func main() { } } +func TestRunBuildSupportsTargetFlag(t *testing.T) { + projectDir := t.TempDir() + writeBuildFixture(t, projectDir, "target-flag", ` +module example.com/target-flag + +go 1.25.0 +`, ` +binary_name = "target-flag" +`, ` +package main + +import "fmt" + +var version = "dev" + +func main() { + fmt.Print(version) +} +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run([]string{ + "build", + "--manifest-dir", projectDir, + "--target", runtime.GOOS + "/" + runtime.GOARCH, + "--version", "2.0.0", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) + } + + artifactPath := artifactPath(projectDir, "build", "target-flag", runtime.GOOS, runtime.GOARCH) + output, err := exec.Command(artifactPath).Output() + if err != nil { + t.Fatalf("run artifact: %v", err) + } + if strings.TrimSpace(string(output)) != "2.0.0" { + t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "2.0.0") + } +} + +func TestRunBuildPlanGithubFormat(t *testing.T) { + projectDir := t.TempDir() + writeBuildFixture(t, projectDir, "matrix-mcp", ` +module example.com/matrix-mcp + +go 1.25.0 +`, ` +binary_name = "matrix-mcp" + +[build] +output_dir = "dist" +[[build.targets]] +os = "linux" +arch = "amd64" +[[build.targets]] +os = "darwin" +arch = "arm64" +`, ` +package main + +func main() {} +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run([]string{ + "build", "plan", + "--manifest-dir", projectDir, + "--format", "github", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) + } + + var payload struct { + Include []struct { + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + Target string `json:"target"` + ArtifactPath string `json:"artifact_path"` + } `json:"include"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode github matrix: %v", err) + } + if len(payload.Include) != 2 { + t.Fatalf("include length = %d, want 2", len(payload.Include)) + } + + if payload.Include[0].Target != "linux/amd64" { + t.Fatalf("first target = %q", payload.Include[0].Target) + } + if payload.Include[0].ArtifactPath != "dist/matrix-mcp-linux-amd64" { + t.Fatalf("first artifact path = %q", payload.Include[0].ArtifactPath) + } + if payload.Include[1].Target != "darwin/arm64" { + t.Fatalf("second target = %q", payload.Include[1].Target) + } +} + func TestBuildHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -125,6 +228,9 @@ func TestBuildHelp(t *testing.T) { if !strings.Contains(output, "version_var") { t.Fatalf("build help should mention manifest build config: %q", output) } + if !strings.Contains(output, "build plan") { + t.Fatalf("build help should mention build plan: %q", output) + } } func writeBuildFixture(t *testing.T, projectDir, binaryName, goModContent, manifestContent, mainContent string) { diff --git a/manifest/manifest.go b/manifest/manifest.go index b9238f3..5c686c7 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -40,9 +40,15 @@ type Update struct { } type Build struct { - MainPackage string `toml:"main_package"` - OutputDir string `toml:"output_dir"` - VersionVar string `toml:"version_var"` + MainPackage string `toml:"main_package"` + OutputDir string `toml:"output_dir"` + VersionVar string `toml:"version_var"` + Targets []BuildTarget `toml:"targets"` +} + +type BuildTarget struct { + OS string `toml:"os"` + Arch string `toml:"arch"` } type Environment struct { @@ -172,6 +178,7 @@ func (b *Build) normalize() { b.MainPackage = strings.TrimSpace(b.MainPackage) b.OutputDir = strings.TrimSpace(b.OutputDir) b.VersionVar = strings.TrimSpace(b.VersionVar) + b.Targets = normalizeBuildTargets(b.Targets) } func (e *Environment) normalize() { @@ -243,3 +250,30 @@ func normalizeStringList(values []string) []string { } return normalized } + +func normalizeBuildTargets(values []BuildTarget) []BuildTarget { + if len(values) == 0 { + return values + } + + normalized := values[:0] + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + target := BuildTarget{ + OS: strings.ToLower(strings.TrimSpace(value.OS)), + Arch: strings.ToLower(strings.TrimSpace(value.Arch)), + } + if target.OS == "" || target.Arch == "" { + continue + } + + key := target.OS + "/" + target.Arch + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, target) + } + + return normalized +} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 6ccf1d6..42e632f 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -155,6 +155,18 @@ latest_release_url = "https://example.com/latest" main_package = " ./cmd/my-mcp " output_dir = " build " version_var = " main.version " +[[build.targets]] +os = " Linux " +arch = " AMD64 " +[[build.targets]] +os = "darwin" +arch = "arm64" +[[build.targets]] +os = "linux" +arch = "amd64" +[[build.targets]] +os = "" +arch = "amd64" [environment] known = [" MCP_PROFILE ", "", "MCP_TOKEN"] @@ -197,6 +209,12 @@ description = " Client MCP interne " if file.Build.VersionVar != "main.version" { t.Fatalf("build version var = %q", file.Build.VersionVar) } + if !slices.Equal(file.Build.Targets, []BuildTarget{ + {OS: "linux", Arch: "amd64"}, + {OS: "darwin", Arch: "arm64"}, + }) { + t.Fatalf("build targets = %#v", file.Build.Targets) + } if file.SecretStore.BackendPolicy != "auto" { t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy) } diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 7a543f7..3ee85da 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -665,6 +665,9 @@ token_env_names = ["{{.ReleaseTokenEnv}}"] main_package = "./cmd/{{.BinaryName}}" output_dir = "build" version_var = "main.version" +[[build.targets]] +os = "linux" +arch = "amd64" [environment] known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 7425d65..e7ad407 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -90,6 +90,9 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "[environment]", "[profiles]", "version_var = \"main.version\"", + "[[build.targets]]", + "os = \"linux\"", + "arch = \"amd64\"", "backend_policy = \"auto\"", } { if !strings.Contains(string(manifestContent), snippet) { -- 2.45.2 From 5e115893cc2d9d71c28b0d26bf6da1eb893e2827 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 09:37:01 +0200 Subject: [PATCH 24/58] Revert "feat: add CI build matrix planning from manifest targets" This reverts commit f5e52463f260af95680cf86acc427e3918d138da. --- README.md | 67 ------- cmd/mcp-framework/build.go | 307 +++----------------------------- cmd/mcp-framework/build_test.go | 106 ----------- manifest/manifest.go | 40 +---- manifest/manifest_test.go | 18 -- scaffold/scaffold.go | 3 - scaffold/scaffold_test.go | 3 - 7 files changed, 32 insertions(+), 512 deletions(-) diff --git a/README.md b/README.md index fb61c92..18502d8 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,6 @@ les projets consommateurs : go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build ``` -Et une commande de planification de matrice CI : - -```bash -go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build plan --format github -``` - Par défaut la commande : - lit `mcp.toml` (en remontant les répertoires parents) @@ -70,59 +64,10 @@ Options principales : - `--build-dir` - `--goos` - `--goarch` -- `--target` (`os/arch`, ex: `linux/amd64`) - `--version` - `--version-var` - `--ldflag` (répétable) -`build plan` options principales : - -- `--manifest-dir` -- `--binary` -- `--package` -- `--build-dir` -- `--version-var` -- `--format` (`json`, `github`, `gitlab`) - -Exemple GitHub Actions (matrix dynamique) : - -```yaml -jobs: - plan: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.plan.outputs.matrix }} - steps: - - uses: actions/checkout@v4 - - id: plan - run: echo "matrix=$(mcp-framework build plan --format github | tr -d '\n')" >> "$GITHUB_OUTPUT" - - build: - needs: [plan] - strategy: - matrix: ${{ fromJson(needs.plan.outputs.matrix) }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: mcp-framework build --target "${{ matrix.target }}" --version "${{ github.ref_name }}" - - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact_name }} - path: ${{ matrix.artifact_path }} -``` - -Exemple GitLab CI (snippet matrix généré) : - -```bash -mcp-framework build plan --format gitlab > matrix.yml -``` - -Puis inclure le snippet `parallel.matrix` dans le job de build et exécuter : - -```bash -mcp-framework build --target "${GOOS}/${GOARCH}" --version "${CI_COMMIT_TAG}" -``` - ## Packages - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. @@ -216,17 +161,6 @@ token_header = "Authorization" token_prefix = "token" token_env_names = ["GITEA_TOKEN"] -[build] -main_package = "./cmd/my-mcp" -output_dir = "build" -version_var = "main.version" -[[build.targets]] -os = "linux" -arch = "amd64" -[[build.targets]] -os = "darwin" -arch = "arm64" - [environment] known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"] @@ -262,7 +196,6 @@ Champs supportés : - `[build].main_package` : package main à builder (ex: `./cmd/my-mcp`). - `[build].output_dir` : répertoire de sortie des artefacts (défaut `build`). - `[build].version_var` : variable Go ciblée par `-X` pour injecter la version (`main.version`, `gitlab.../internal/app.Version`, etc.). -- `[[build.targets]]` : cibles de compilation (`os`, `arch`) exploitées par `mcp-framework build plan`. - `[environment].known` : variables d'environnement connues du projet. - `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`). - `[profiles].default` : profil recommandé par défaut. diff --git a/cmd/mcp-framework/build.go b/cmd/mcp-framework/build.go index 1e62344..de70f8d 100644 --- a/cmd/mcp-framework/build.go +++ b/cmd/mcp-framework/build.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "errors" "flag" "fmt" @@ -17,43 +16,6 @@ import ( type stringListFlag []string -type buildTarget struct { - GOOS string - GOARCH string -} - -type buildConfig struct { - ProjectDir string - BinaryName string - MainPackage string - BuildDir string - VersionVar string - ManifestTargets []manifestpkg.BuildTarget -} - -type buildConfigOverrides struct { - BinaryName string - MainPackage string - BuildDir string - VersionVar string -} - -type buildPlanTarget struct { - GOOS string `json:"goos"` - GOARCH string `json:"goarch"` - Target string `json:"target"` - ArtifactName string `json:"artifact_name"` - ArtifactPath string `json:"artifact_path"` -} - -type buildPlanOutput struct { - BinaryName string `json:"binary_name"` - MainPackage string `json:"main_package"` - BuildDir string `json:"build_dir"` - VersionVar string `json:"version_var"` - Targets []buildPlanTarget `json:"targets"` -} - func (f *stringListFlag) String() string { return strings.Join(*f, ",") } @@ -64,13 +26,6 @@ func (f *stringListFlag) Set(value string) error { } func runBuild(args []string, stdout, stderr io.Writer) error { - if len(args) > 0 { - switch strings.TrimSpace(args[0]) { - case "plan": - return runBuildPlan(args[1:], stdout, stderr) - } - } - if shouldShowHelp(args) { printBuildHelp(stdout) return nil @@ -79,8 +34,15 @@ func runBuild(args []string, stdout, stderr io.Writer) error { fs := flag.NewFlagSet("build", flag.ContinueOnError) fs.SetOutput(io.Discard) - defaultGOOS := resolveDefaultGOOS() - defaultGOARCH := resolveDefaultGOARCH() + defaultGOOS := strings.TrimSpace(os.Getenv("GOOS")) + if defaultGOOS == "" { + defaultGOOS = runtime.GOOS + } + + defaultGOARCH := strings.TrimSpace(os.Getenv("GOARCH")) + if defaultGOARCH == "" { + defaultGOARCH = runtime.GOARCH + } var manifestDir string var binaryName string @@ -88,7 +50,6 @@ func runBuild(args []string, stdout, stderr io.Writer) error { var buildDir string var goos string var goarch string - var target string var version string var versionVar string var gocache string @@ -100,7 +61,6 @@ func runBuild(args []string, stdout, stderr io.Writer) error { fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") fs.StringVar(&goos, "goos", defaultGOOS, "GOOS cible") fs.StringVar(&goarch, "goarch", defaultGOARCH, "GOARCH cible") - fs.StringVar(&target, "target", "", "Cible au format os/arch (override --goos/--goarch)") fs.StringVar(&version, "version", "", "Version injectée (par défaut VERSION env, puis git describe, puis dev)") fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") fs.StringVar(&gocache, "gocache", strings.TrimSpace(os.Getenv("GOCACHE")), "Valeur GOCACHE pour go build") @@ -115,31 +75,30 @@ func runBuild(args []string, stdout, stderr io.Writer) error { return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) } - cfg, err := resolveBuildConfig(manifestDir, buildConfigOverrides{ - BinaryName: binaryName, - MainPackage: mainPackage, - BuildDir: buildDir, - VersionVar: versionVar, - }) + file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) if err != nil { return err } - if strings.TrimSpace(target) != "" { - parsedTarget, err := parseBuildTarget(target) - if err != nil { - return err - } - goos = parsedTarget.GOOS - goarch = parsedTarget.GOARCH + projectDir := filepath.Dir(manifestPath) + + binaryName = firstNonEmpty(binaryName, file.BinaryName) + if binaryName == "" { + return errors.New("binary name is required (set binary_name in mcp.toml or --binary)") } - goos = firstNonEmpty(goos, resolveDefaultGOOS()) - goarch = firstNonEmpty(goarch, resolveDefaultGOARCH()) - version = resolveBuildVersion(version, cfg.ProjectDir) - versionVar = cfg.VersionVar + mainPackage = firstNonEmpty(mainPackage, file.Build.MainPackage) + if mainPackage == "" { + mainPackage = fmt.Sprintf("./cmd/%s", binaryName) + } - outputPath, err := buildOutputPath(cfg.ProjectDir, cfg.BuildDir, cfg.BinaryName, goos, goarch) + buildDir = firstNonEmpty(buildDir, file.Build.OutputDir, "build") + goos = firstNonEmpty(goos, runtime.GOOS) + goarch = firstNonEmpty(goarch, runtime.GOARCH) + version = resolveBuildVersion(version, projectDir) + versionVar = firstNonEmpty(versionVar, file.Build.VersionVar, "main.version") + + outputPath, err := buildOutputPath(projectDir, buildDir, binaryName, goos, goarch) if err != nil { return err } @@ -161,10 +120,10 @@ func runBuild(args []string, stdout, stderr io.Writer) error { if len(ldflags) > 0 { cmdArgs = append(cmdArgs, "-ldflags", strings.Join(ldflags, " ")) } - cmdArgs = append(cmdArgs, "-o", outputPath, cfg.MainPackage) + cmdArgs = append(cmdArgs, "-o", outputPath, mainPackage) cmd := exec.Command("go", cmdArgs...) - cmd.Dir = cfg.ProjectDir + cmd.Dir = projectDir cmd.Stdout = stdout cmd.Stderr = stderr cmd.Env = withEnvOverrides(os.Environ(), map[string]string{ @@ -184,210 +143,10 @@ func runBuild(args []string, stdout, stderr io.Writer) error { return nil } -func runBuildPlan(args []string, stdout, stderr io.Writer) error { - if shouldShowHelp(args) { - printBuildPlanHelp(stdout) - return nil - } - - fs := flag.NewFlagSet("build plan", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - var manifestDir string - var binaryName string - var mainPackage string - var buildDir string - var versionVar string - var format string - - fs.StringVar(&manifestDir, "manifest-dir", ".", "Répertoire de départ pour trouver mcp.toml") - fs.StringVar(&binaryName, "binary", "", "Nom du binaire (override binary_name)") - fs.StringVar(&mainPackage, "package", "", "Package main à builder (override [build].main_package)") - fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") - fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") - fs.StringVar(&format, "format", "json", "Format de sortie: json|github|gitlab") - - if err := fs.Parse(args); err != nil { - _ = stderr - return fmt.Errorf("parse build plan flags: %w", err) - } - - if fs.NArg() > 0 { - return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) - } - - cfg, err := resolveBuildConfig(manifestDir, buildConfigOverrides{ - BinaryName: binaryName, - MainPackage: mainPackage, - BuildDir: buildDir, - VersionVar: versionVar, - }) - if err != nil { - return err - } - - targets, err := resolveBuildTargets(cfg.ManifestTargets, resolveDefaultGOOS(), resolveDefaultGOARCH()) - if err != nil { - return err - } - - planTargets := make([]buildPlanTarget, 0, len(targets)) - for _, target := range targets { - outputPath, err := buildOutputPath(cfg.ProjectDir, cfg.BuildDir, cfg.BinaryName, target.GOOS, target.GOARCH) - if err != nil { - return err - } - - planTargets = append(planTargets, buildPlanTarget{ - GOOS: target.GOOS, - GOARCH: target.GOARCH, - Target: formatBuildTarget(target.GOOS, target.GOARCH), - ArtifactName: filepath.Base(outputPath), - ArtifactPath: formatArtifactPath(cfg.ProjectDir, outputPath), - }) - } - - switch strings.ToLower(strings.TrimSpace(format)) { - case "json": - return renderJSON(stdout, buildPlanOutput{ - BinaryName: cfg.BinaryName, - MainPackage: cfg.MainPackage, - BuildDir: cfg.BuildDir, - VersionVar: cfg.VersionVar, - Targets: planTargets, - }) - case "github": - include := make([]map[string]string, 0, len(planTargets)) - for _, target := range planTargets { - include = append(include, map[string]string{ - "goos": target.GOOS, - "goarch": target.GOARCH, - "target": target.Target, - "artifact_name": target.ArtifactName, - "artifact_path": target.ArtifactPath, - }) - } - return renderJSON(stdout, map[string]any{"include": include}) - case "gitlab": - return renderGitLabMatrix(stdout, planTargets) - default: - return fmt.Errorf("unsupported build plan format %q (expected json, github or gitlab)", format) - } -} - -func resolveBuildConfig(manifestDir string, overrides buildConfigOverrides) (buildConfig, error) { - file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) - if err != nil { - return buildConfig{}, err - } - - projectDir := filepath.Dir(manifestPath) - binaryName := firstNonEmpty(overrides.BinaryName, file.BinaryName) - if binaryName == "" { - return buildConfig{}, errors.New("binary name is required (set binary_name in mcp.toml or --binary)") - } - - mainPackage := firstNonEmpty(overrides.MainPackage, file.Build.MainPackage) - if mainPackage == "" { - mainPackage = fmt.Sprintf("./cmd/%s", binaryName) - } - - return buildConfig{ - ProjectDir: projectDir, - BinaryName: binaryName, - MainPackage: mainPackage, - BuildDir: firstNonEmpty(overrides.BuildDir, file.Build.OutputDir, "build"), - VersionVar: firstNonEmpty(overrides.VersionVar, file.Build.VersionVar, "main.version"), - ManifestTargets: append([]manifestpkg.BuildTarget(nil), file.Build.Targets...), - }, nil -} - -func resolveBuildTargets(manifestTargets []manifestpkg.BuildTarget, defaultGOOS, defaultGOARCH string) ([]buildTarget, error) { - if len(manifestTargets) == 0 { - defaultTarget, err := parseBuildTarget(formatBuildTarget(defaultGOOS, defaultGOARCH)) - if err != nil { - return nil, err - } - return []buildTarget{defaultTarget}, nil - } - - targets := make([]buildTarget, 0, len(manifestTargets)) - for _, manifestTarget := range manifestTargets { - target, err := parseBuildTarget(formatBuildTarget(manifestTarget.OS, manifestTarget.Arch)) - if err != nil { - return nil, err - } - targets = append(targets, target) - } - - return targets, nil -} - -func parseBuildTarget(value string) (buildTarget, error) { - raw := strings.TrimSpace(value) - parts := strings.Split(raw, "/") - if len(parts) != 2 { - return buildTarget{}, fmt.Errorf("invalid target %q (expected os/arch)", value) - } - - goos := strings.ToLower(strings.TrimSpace(parts[0])) - goarch := strings.ToLower(strings.TrimSpace(parts[1])) - if goos == "" || goarch == "" { - return buildTarget{}, fmt.Errorf("invalid target %q (expected non-empty os/arch)", value) - } - - return buildTarget{GOOS: goos, GOARCH: goarch}, nil -} - -func formatBuildTarget(goos, goarch string) string { - return strings.ToLower(strings.TrimSpace(goos)) + "/" + strings.ToLower(strings.TrimSpace(goarch)) -} - -func formatArtifactPath(projectDir, outputPath string) string { - rel, err := filepath.Rel(projectDir, outputPath) - if err == nil && rel != "" && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) { - return filepath.ToSlash(rel) - } - return filepath.ToSlash(outputPath) -} - -func renderJSON(w io.Writer, payload any) error { - encoder := json.NewEncoder(w) - encoder.SetIndent("", " ") - return encoder.Encode(payload) -} - -func renderGitLabMatrix(w io.Writer, targets []buildPlanTarget) error { - if _, err := fmt.Fprintln(w, "parallel:"); err != nil { - return err - } - if _, err := fmt.Fprintln(w, " matrix:"); err != nil { - return err - } - for _, target := range targets { - if _, err := fmt.Fprintf(w, " - GOOS: %q\n", target.GOOS); err != nil { - return err - } - if _, err := fmt.Fprintf(w, " GOARCH: %q\n", target.GOARCH); err != nil { - return err - } - } - return nil -} - func printBuildHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s build [flags]\n %s build plan [flags]\n\nBuild flags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --target Cible os/arch (ex: linux/amd64), prioritaire sur --goos/--goarch\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nBuild plan flags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --version-var Override de [build].version_var\n --format json|github|gitlab\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n [[build.targets]]\n os = \"linux\"\n arch = \"amd64\"\n", - toolName, - toolName, - ) -} - -func printBuildPlanHelp(w io.Writer) { - fmt.Fprintf( - w, - "Usage:\n %s build plan [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --version-var Override de [build].version_var\n --format json|github|gitlab (défaut: json)\n", + "Usage:\n %s build [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n", toolName, ) } @@ -473,11 +232,3 @@ func firstNonEmpty(values ...string) string { } return "" } - -func resolveDefaultGOOS() string { - return firstNonEmpty(os.Getenv("GOOS"), runtime.GOOS) -} - -func resolveDefaultGOARCH() string { - return firstNonEmpty(os.Getenv("GOARCH"), runtime.GOARCH) -} diff --git a/cmd/mcp-framework/build_test.go b/cmd/mcp-framework/build_test.go index 85b9718..5fa1fd4 100644 --- a/cmd/mcp-framework/build_test.go +++ b/cmd/mcp-framework/build_test.go @@ -2,7 +2,6 @@ package main import ( "bytes" - "encoding/json" "os" "os/exec" "path/filepath" @@ -111,108 +110,6 @@ func main() { } } -func TestRunBuildSupportsTargetFlag(t *testing.T) { - projectDir := t.TempDir() - writeBuildFixture(t, projectDir, "target-flag", ` -module example.com/target-flag - -go 1.25.0 -`, ` -binary_name = "target-flag" -`, ` -package main - -import "fmt" - -var version = "dev" - -func main() { - fmt.Print(version) -} -`) - - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run([]string{ - "build", - "--manifest-dir", projectDir, - "--target", runtime.GOOS + "/" + runtime.GOARCH, - "--version", "2.0.0", - }, &stdout, &stderr) - if err != nil { - t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) - } - - artifactPath := artifactPath(projectDir, "build", "target-flag", runtime.GOOS, runtime.GOARCH) - output, err := exec.Command(artifactPath).Output() - if err != nil { - t.Fatalf("run artifact: %v", err) - } - if strings.TrimSpace(string(output)) != "2.0.0" { - t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "2.0.0") - } -} - -func TestRunBuildPlanGithubFormat(t *testing.T) { - projectDir := t.TempDir() - writeBuildFixture(t, projectDir, "matrix-mcp", ` -module example.com/matrix-mcp - -go 1.25.0 -`, ` -binary_name = "matrix-mcp" - -[build] -output_dir = "dist" -[[build.targets]] -os = "linux" -arch = "amd64" -[[build.targets]] -os = "darwin" -arch = "arm64" -`, ` -package main - -func main() {} -`) - - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run([]string{ - "build", "plan", - "--manifest-dir", projectDir, - "--format", "github", - }, &stdout, &stderr) - if err != nil { - t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) - } - - var payload struct { - Include []struct { - GOOS string `json:"goos"` - GOARCH string `json:"goarch"` - Target string `json:"target"` - ArtifactPath string `json:"artifact_path"` - } `json:"include"` - } - if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { - t.Fatalf("decode github matrix: %v", err) - } - if len(payload.Include) != 2 { - t.Fatalf("include length = %d, want 2", len(payload.Include)) - } - - if payload.Include[0].Target != "linux/amd64" { - t.Fatalf("first target = %q", payload.Include[0].Target) - } - if payload.Include[0].ArtifactPath != "dist/matrix-mcp-linux-amd64" { - t.Fatalf("first artifact path = %q", payload.Include[0].ArtifactPath) - } - if payload.Include[1].Target != "darwin/arm64" { - t.Fatalf("second target = %q", payload.Include[1].Target) - } -} - func TestBuildHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -228,9 +125,6 @@ func TestBuildHelp(t *testing.T) { if !strings.Contains(output, "version_var") { t.Fatalf("build help should mention manifest build config: %q", output) } - if !strings.Contains(output, "build plan") { - t.Fatalf("build help should mention build plan: %q", output) - } } func writeBuildFixture(t *testing.T, projectDir, binaryName, goModContent, manifestContent, mainContent string) { diff --git a/manifest/manifest.go b/manifest/manifest.go index 5c686c7..b9238f3 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -40,15 +40,9 @@ type Update struct { } type Build struct { - MainPackage string `toml:"main_package"` - OutputDir string `toml:"output_dir"` - VersionVar string `toml:"version_var"` - Targets []BuildTarget `toml:"targets"` -} - -type BuildTarget struct { - OS string `toml:"os"` - Arch string `toml:"arch"` + MainPackage string `toml:"main_package"` + OutputDir string `toml:"output_dir"` + VersionVar string `toml:"version_var"` } type Environment struct { @@ -178,7 +172,6 @@ func (b *Build) normalize() { b.MainPackage = strings.TrimSpace(b.MainPackage) b.OutputDir = strings.TrimSpace(b.OutputDir) b.VersionVar = strings.TrimSpace(b.VersionVar) - b.Targets = normalizeBuildTargets(b.Targets) } func (e *Environment) normalize() { @@ -250,30 +243,3 @@ func normalizeStringList(values []string) []string { } return normalized } - -func normalizeBuildTargets(values []BuildTarget) []BuildTarget { - if len(values) == 0 { - return values - } - - normalized := values[:0] - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - target := BuildTarget{ - OS: strings.ToLower(strings.TrimSpace(value.OS)), - Arch: strings.ToLower(strings.TrimSpace(value.Arch)), - } - if target.OS == "" || target.Arch == "" { - continue - } - - key := target.OS + "/" + target.Arch - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - normalized = append(normalized, target) - } - - return normalized -} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 42e632f..6ccf1d6 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -155,18 +155,6 @@ latest_release_url = "https://example.com/latest" main_package = " ./cmd/my-mcp " output_dir = " build " version_var = " main.version " -[[build.targets]] -os = " Linux " -arch = " AMD64 " -[[build.targets]] -os = "darwin" -arch = "arm64" -[[build.targets]] -os = "linux" -arch = "amd64" -[[build.targets]] -os = "" -arch = "amd64" [environment] known = [" MCP_PROFILE ", "", "MCP_TOKEN"] @@ -209,12 +197,6 @@ description = " Client MCP interne " if file.Build.VersionVar != "main.version" { t.Fatalf("build version var = %q", file.Build.VersionVar) } - if !slices.Equal(file.Build.Targets, []BuildTarget{ - {OS: "linux", Arch: "amd64"}, - {OS: "darwin", Arch: "arm64"}, - }) { - t.Fatalf("build targets = %#v", file.Build.Targets) - } if file.SecretStore.BackendPolicy != "auto" { t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy) } diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 3ee85da..7a543f7 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -665,9 +665,6 @@ token_env_names = ["{{.ReleaseTokenEnv}}"] main_package = "./cmd/{{.BinaryName}}" output_dir = "build" version_var = "main.version" -[[build.targets]] -os = "linux" -arch = "amd64" [environment] known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index e7ad407..7425d65 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -90,9 +90,6 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "[environment]", "[profiles]", "version_var = \"main.version\"", - "[[build.targets]]", - "os = \"linux\"", - "arch = \"amd64\"", "backend_policy = \"auto\"", } { if !strings.Contains(string(manifestContent), snippet) { -- 2.45.2 From 7c239a7e9771fa00a0b01e3e4033d334ac824520 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 09:37:03 +0200 Subject: [PATCH 25/58] Revert "feat: add unified build command driven by mcp.toml" This reverts commit 845d20541b5f667ed7305d5045e025fd9e4b8af8. --- README.md | 34 ----- cmd/mcp-framework/build.go | 234 -------------------------------- cmd/mcp-framework/build_test.go | 155 --------------------- cmd/mcp-framework/main.go | 4 +- cmd/mcp-framework/main_test.go | 3 - manifest/manifest.go | 14 -- manifest/manifest_test.go | 14 -- scaffold/scaffold.go | 5 - scaffold/scaffold_test.go | 2 - 9 files changed, 1 insertion(+), 464 deletions(-) delete mode 100644 cmd/mcp-framework/build.go delete mode 100644 cmd/mcp-framework/build_test.go diff --git a/README.md b/README.md index 18502d8..f9145a8 100644 --- a/README.md +++ b/README.md @@ -38,36 +38,6 @@ go mod tidy go run ./cmd/my-mcp help ``` -## CLI de build unifiée - -Le binaire `mcp-framework` expose aussi une commande de build standardisée pour -les projets consommateurs : - -```bash -go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build -``` - -Par défaut la commande : - -- lit `mcp.toml` (en remontant les répertoires parents) -- récupère `binary_name` -- build `./cmd/` -- produit `build/--{.exe}` -- injecte la version via `-X main.version=` - (`VERSION` env, sinon `git describe`, sinon `dev`) - -Options principales : - -- `--manifest-dir` -- `--binary` -- `--package` -- `--build-dir` -- `--goos` -- `--goarch` -- `--version` -- `--version-var` -- `--ldflag` (répétable) - ## Packages - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. @@ -180,7 +150,6 @@ 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`. -- `[build]` : paramètres de build pour `mcp-framework build`. - `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. @@ -193,9 +162,6 @@ Champs supportés : - `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. -- `[build].main_package` : package main à builder (ex: `./cmd/my-mcp`). -- `[build].output_dir` : répertoire de sortie des artefacts (défaut `build`). -- `[build].version_var` : variable Go ciblée par `-X` pour injecter la version (`main.version`, `gitlab.../internal/app.Version`, etc.). - `[environment].known` : variables d'environnement connues du projet. - `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`). - `[profiles].default` : profil recommandé par défaut. diff --git a/cmd/mcp-framework/build.go b/cmd/mcp-framework/build.go deleted file mode 100644 index de70f8d..0000000 --- a/cmd/mcp-framework/build.go +++ /dev/null @@ -1,234 +0,0 @@ -package main - -import ( - "errors" - "flag" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - - manifestpkg "gitea.lclr.dev/AI/mcp-framework/manifest" -) - -type stringListFlag []string - -func (f *stringListFlag) String() string { - return strings.Join(*f, ",") -} - -func (f *stringListFlag) Set(value string) error { - *f = append(*f, strings.TrimSpace(value)) - return nil -} - -func runBuild(args []string, stdout, stderr io.Writer) error { - if shouldShowHelp(args) { - printBuildHelp(stdout) - return nil - } - - fs := flag.NewFlagSet("build", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - defaultGOOS := strings.TrimSpace(os.Getenv("GOOS")) - if defaultGOOS == "" { - defaultGOOS = runtime.GOOS - } - - defaultGOARCH := strings.TrimSpace(os.Getenv("GOARCH")) - if defaultGOARCH == "" { - defaultGOARCH = runtime.GOARCH - } - - var manifestDir string - var binaryName string - var mainPackage string - var buildDir string - var goos string - var goarch string - var version string - var versionVar string - var gocache string - var extraLDFlags stringListFlag - - fs.StringVar(&manifestDir, "manifest-dir", ".", "Répertoire de départ pour trouver mcp.toml") - fs.StringVar(&binaryName, "binary", "", "Nom du binaire (override binary_name)") - fs.StringVar(&mainPackage, "package", "", "Package main à builder (override [build].main_package)") - fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") - fs.StringVar(&goos, "goos", defaultGOOS, "GOOS cible") - fs.StringVar(&goarch, "goarch", defaultGOARCH, "GOARCH cible") - fs.StringVar(&version, "version", "", "Version injectée (par défaut VERSION env, puis git describe, puis dev)") - fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") - fs.StringVar(&gocache, "gocache", strings.TrimSpace(os.Getenv("GOCACHE")), "Valeur GOCACHE pour go build") - fs.Var(&extraLDFlags, "ldflag", "Option additionnelle passée à -ldflags (répéter si nécessaire)") - - if err := fs.Parse(args); err != nil { - _ = stderr - return fmt.Errorf("parse build flags: %w", err) - } - - if fs.NArg() > 0 { - return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) - } - - file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) - if err != nil { - return err - } - - projectDir := filepath.Dir(manifestPath) - - binaryName = firstNonEmpty(binaryName, file.BinaryName) - if binaryName == "" { - return errors.New("binary name is required (set binary_name in mcp.toml or --binary)") - } - - mainPackage = firstNonEmpty(mainPackage, file.Build.MainPackage) - if mainPackage == "" { - mainPackage = fmt.Sprintf("./cmd/%s", binaryName) - } - - buildDir = firstNonEmpty(buildDir, file.Build.OutputDir, "build") - goos = firstNonEmpty(goos, runtime.GOOS) - goarch = firstNonEmpty(goarch, runtime.GOARCH) - version = resolveBuildVersion(version, projectDir) - versionVar = firstNonEmpty(versionVar, file.Build.VersionVar, "main.version") - - outputPath, err := buildOutputPath(projectDir, buildDir, binaryName, goos, goarch) - if err != nil { - return err - } - - outputDir := filepath.Dir(outputPath) - if err := os.MkdirAll(outputDir, 0o755); err != nil { - return fmt.Errorf("create build directory %q: %w", outputDir, err) - } - - ldflags := normalizeStringList(extraLDFlags) - if versionVar != "-" { - versionVar = strings.TrimSpace(versionVar) - if versionVar != "" { - ldflags = append([]string{fmt.Sprintf("-X %s=%s", versionVar, version)}, ldflags...) - } - } - - cmdArgs := []string{"build"} - if len(ldflags) > 0 { - cmdArgs = append(cmdArgs, "-ldflags", strings.Join(ldflags, " ")) - } - cmdArgs = append(cmdArgs, "-o", outputPath, mainPackage) - - cmd := exec.Command("go", cmdArgs...) - cmd.Dir = projectDir - cmd.Stdout = stdout - cmd.Stderr = stderr - cmd.Env = withEnvOverrides(os.Environ(), map[string]string{ - "GOOS": goos, - "GOARCH": goarch, - "GOCACHE": strings.TrimSpace(gocache), - }) - - if err := cmd.Run(); err != nil { - return fmt.Errorf("go build failed: %w", err) - } - - if _, err := fmt.Fprintf(stdout, "Build artifact: %s\n", outputPath); err != nil { - return err - } - - return nil -} - -func printBuildHelp(w io.Writer) { - fmt.Fprintf( - w, - "Usage:\n %s build [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n", - toolName, - ) -} - -func resolveBuildVersion(explicit, projectDir string) string { - if value := strings.TrimSpace(explicit); value != "" { - return value - } - if value := strings.TrimSpace(os.Getenv("VERSION")); value != "" { - return value - } - - describe := exec.Command("git", "describe", "--tags", "--always", "--dirty") - describe.Dir = projectDir - out, err := describe.Output() - if err == nil { - if value := strings.TrimSpace(string(out)); value != "" { - return value - } - } - - return "dev" -} - -func buildOutputPath(projectDir, buildDir, binaryName, goos, goarch string) (string, error) { - artifactName := fmt.Sprintf("%s-%s-%s", binaryName, goos, goarch) - if goos == "windows" { - artifactName += ".exe" - } - - dir := strings.TrimSpace(buildDir) - if dir == "" { - dir = "build" - } - - if filepath.IsAbs(dir) { - return filepath.Join(dir, artifactName), nil - } - if strings.TrimSpace(projectDir) == "" { - return "", errors.New("project directory is required") - } - return filepath.Join(projectDir, dir, artifactName), nil -} - -func withEnvOverrides(base []string, overrides map[string]string) []string { - result := make([]string, 0, len(base)+len(overrides)) - for _, entry := range base { - key, _, found := strings.Cut(entry, "=") - if !found { - continue - } - if _, overridden := overrides[key]; overridden { - continue - } - result = append(result, entry) - } - - for key, value := range overrides { - if strings.TrimSpace(value) == "" { - continue - } - result = append(result, key+"="+value) - } - - return result -} - -func normalizeStringList(values []string) []string { - normalized := make([]string, 0, len(values)) - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - normalized = append(normalized, trimmed) - } - } - return normalized -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} diff --git a/cmd/mcp-framework/build_test.go b/cmd/mcp-framework/build_test.go deleted file mode 100644 index 5fa1fd4..0000000 --- a/cmd/mcp-framework/build_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package main - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestRunBuildBuildsArtifactFromManifest(t *testing.T) { - projectDir := t.TempDir() - writeBuildFixture(t, projectDir, "demo-mcp", ` -module example.com/demo - -go 1.25.0 -`, ` -binary_name = "demo-mcp" - -[build] -main_package = "./cmd/demo-mcp" -output_dir = "build" -version_var = "main.version" -`, ` -package main - -import "fmt" - -var version = "dev" - -func main() { - fmt.Print(version) -} -`) - - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run([]string{ - "build", - "--manifest-dir", projectDir, - "--goos", runtime.GOOS, - "--goarch", runtime.GOARCH, - "--version", "1.2.3", - }, &stdout, &stderr) - if err != nil { - t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) - } - - artifactPath := artifactPath(projectDir, "build", "demo-mcp", runtime.GOOS, runtime.GOARCH) - if _, err := os.Stat(artifactPath); err != nil { - t.Fatalf("expected artifact at %s: %v", artifactPath, err) - } - - output, err := exec.Command(artifactPath).Output() - if err != nil { - t.Fatalf("run artifact: %v", err) - } - if strings.TrimSpace(string(output)) != "1.2.3" { - t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "1.2.3") - } -} - -func TestRunBuildUsesManifestVersionVar(t *testing.T) { - projectDir := t.TempDir() - writeBuildFixture(t, projectDir, "custom-version", ` -module example.com/custom-version - -go 1.25.0 -`, ` -binary_name = "custom-version" - -[build] -main_package = "./cmd/custom-version" -output_dir = "dist" -version_var = "main.releaseVersion" -`, ` -package main - -import "fmt" - -var releaseVersion = "dev" - -func main() { - fmt.Print(releaseVersion) -} -`) - - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run([]string{ - "build", - "--manifest-dir", projectDir, - "--goos", runtime.GOOS, - "--goarch", runtime.GOARCH, - "--version", "9.9.9", - }, &stdout, &stderr) - if err != nil { - t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) - } - - artifactPath := artifactPath(projectDir, "dist", "custom-version", runtime.GOOS, runtime.GOARCH) - output, err := exec.Command(artifactPath).Output() - if err != nil { - t.Fatalf("run artifact: %v", err) - } - if strings.TrimSpace(string(output)) != "9.9.9" { - t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "9.9.9") - } -} - -func TestBuildHelp(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - if err := run([]string{"build", "--help"}, &stdout, &stderr); err != nil { - t.Fatalf("run returned error: %v", err) - } - - output := stdout.String() - if !strings.Contains(output, "--manifest-dir") { - t.Fatalf("build help should mention --manifest-dir: %q", output) - } - if !strings.Contains(output, "version_var") { - t.Fatalf("build help should mention manifest build config: %q", output) - } -} - -func writeBuildFixture(t *testing.T, projectDir, binaryName, goModContent, manifestContent, mainContent string) { - t.Helper() - - if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(strings.TrimSpace(goModContent)+"\n"), 0o644); err != nil { - t.Fatalf("write go.mod: %v", err) - } - if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(strings.TrimSpace(manifestContent)+"\n"), 0o644); err != nil { - t.Fatalf("write mcp.toml: %v", err) - } - - cmdDir := filepath.Join(projectDir, "cmd", binaryName) - if err := os.MkdirAll(cmdDir, 0o755); err != nil { - t.Fatalf("mkdir cmd dir: %v", err) - } - if err := os.WriteFile(filepath.Join(cmdDir, "main.go"), []byte(strings.TrimSpace(mainContent)+"\n"), 0o644); err != nil { - t.Fatalf("write main.go: %v", err) - } -} - -func artifactPath(projectDir, outDir, binaryName, goos, goarch string) string { - name := binaryName + "-" + goos + "-" + goarch - if goos == "windows" { - name += ".exe" - } - return filepath.Join(projectDir, outDir, name) -} diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go index d380a8c..d6f98b9 100644 --- a/cmd/mcp-framework/main.go +++ b/cmd/mcp-framework/main.go @@ -34,8 +34,6 @@ func run(args []string, stdout, stderr io.Writer) error { } switch args[0] { - case "build": - return runBuild(args[1:], stdout, stderr) case "scaffold": return runScaffold(args[1:], stdout, stderr) default: @@ -144,7 +142,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error { func printGlobalHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s [options]\n\nCommands:\n build Build un binaire MCP de manière standardisée\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", + "Usage:\n %s [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", toolName, toolName, ) diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go index 1476bf7..6f32c6f 100644 --- a/cmd/mcp-framework/main_test.go +++ b/cmd/mcp-framework/main_test.go @@ -23,9 +23,6 @@ func TestRunPrintsGlobalHelp(t *testing.T) { if !strings.Contains(output, "scaffold init") { t.Fatalf("global help should mention scaffold init: %q", output) } - if !strings.Contains(output, "build") { - t.Fatalf("global help should mention build: %q", output) - } } func TestRunScaffoldInitCreatesProject(t *testing.T) { diff --git a/manifest/manifest.go b/manifest/manifest.go index b9238f3..b3c9414 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -18,7 +18,6 @@ type File struct { BinaryName string `toml:"binary_name"` DocsURL string `toml:"docs_url"` Update Update `toml:"update"` - Build Build `toml:"build"` Environment Environment `toml:"environment"` SecretStore SecretStore `toml:"secret_store"` Profiles Profiles `toml:"profiles"` @@ -39,12 +38,6 @@ type Update struct { TokenEnvNames []string `toml:"token_env_names"` } -type Build struct { - MainPackage string `toml:"main_package"` - OutputDir string `toml:"output_dir"` - VersionVar string `toml:"version_var"` -} - type Environment struct { Known []string `toml:"known"` } @@ -148,7 +141,6 @@ func (f *File) normalize() { f.BinaryName = strings.TrimSpace(f.BinaryName) f.DocsURL = strings.TrimSpace(f.DocsURL) f.Update.normalize() - f.Build.normalize() f.Environment.normalize() f.SecretStore.normalize() f.Profiles.normalize() @@ -168,12 +160,6 @@ func (u *Update) normalize() { u.TokenEnvNames = normalizeStringList(u.TokenEnvNames) } -func (b *Build) normalize() { - b.MainPackage = strings.TrimSpace(b.MainPackage) - b.OutputDir = strings.TrimSpace(b.OutputDir) - b.VersionVar = strings.TrimSpace(b.VersionVar) -} - func (e *Environment) normalize() { e.Known = normalizeStringList(e.Known) } diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 6ccf1d6..83ea7d5 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -151,11 +151,6 @@ docs_url = " https://docs.example.com/mcp " [update] latest_release_url = "https://example.com/latest" -[build] -main_package = " ./cmd/my-mcp " -output_dir = " build " -version_var = " main.version " - [environment] known = [" MCP_PROFILE ", "", "MCP_TOKEN"] @@ -188,15 +183,6 @@ description = " Client MCP interne " if !slices.Equal(file.Environment.Known, []string{"MCP_PROFILE", "MCP_TOKEN"}) { t.Fatalf("environment known = %v", file.Environment.Known) } - if file.Build.MainPackage != "./cmd/my-mcp" { - t.Fatalf("build main package = %q", file.Build.MainPackage) - } - if file.Build.OutputDir != "build" { - t.Fatalf("build output dir = %q", file.Build.OutputDir) - } - if file.Build.VersionVar != "main.version" { - t.Fatalf("build version var = %q", file.Build.VersionVar) - } if file.SecretStore.BackendPolicy != "auto" { t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy) } diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 7a543f7..bd2c96e 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -661,11 +661,6 @@ token_header = "Authorization" token_prefix = "token" token_env_names = ["{{.ReleaseTokenEnv}}"] -[build] -main_package = "./cmd/{{.BinaryName}}" -output_dir = "build" -version_var = "main.version" - [environment] known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 7425d65..c166a3b 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -85,11 +85,9 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "binary_name = \"my-mcp\"", "[update]", - "[build]", "[secret_store]", "[environment]", "[profiles]", - "version_var = \"main.version\"", "backend_policy = \"auto\"", } { if !strings.Contains(string(manifestContent), snippet) { -- 2.45.2 From f80eebb5753ac9f1e71e16f050790f24706dfd78 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 10:13:45 +0200 Subject: [PATCH 26/58] feat(scaffold): inject install.sh wizard in generated projects --- README.md | 3 +- cmd/mcp-framework/main_test.go | 3 + scaffold/scaffold.go | 201 +++++++++++++++++++++++++++++++-- scaffold/scaffold_test.go | 28 +++++ 4 files changed, 225 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f9145a8..243a2e5 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ go run ./cmd/my-mcp help - `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, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. -- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, wiring de base et README de démarrage). +- `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 helper runtime `OpenFromManifest`. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. @@ -191,6 +191,7 @@ _ = scaffoldInfo Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : - arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) +- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard setup/JSON MCP - wiring initial `bootstrap + config + secretstore + update` - `README.md` de démarrage diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go index 6f32c6f..0b17192 100644 --- a/cmd/mcp-framework/main_test.go +++ b/cmd/mcp-framework/main_test.go @@ -51,6 +51,9 @@ func TestRunScaffoldInitCreatesProject(t *testing.T) { 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()) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index bd2c96e..01eca44 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -70,18 +70,19 @@ func Generate(options Options) (Result, error) { } files := []generatedFile{ - {Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized)}, - {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized)}, - {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized)}, - {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized)}, - {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized)}, - {Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized)}, + {Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized), Mode: 0o644}, + {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized), Mode: 0o644}, + {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized), Mode: 0o644}, + {Path: "install.sh", Content: renderTemplate(installTemplate, normalized), Mode: 0o755}, + {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized), Mode: 0o644}, + {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized), Mode: 0o644}, + {Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized), Mode: 0o644}, } written := make([]string, 0, len(files)) for _, file := range files { fullPath := filepath.Join(normalized.TargetDir, file.Path) - if err := writeFile(fullPath, file.Content, normalized.Overwrite); err != nil { + if err := writeFile(fullPath, file.Content, file.Mode, normalized.Overwrite); err != nil { return Result{}, err } written = append(written, file.Path) @@ -97,9 +98,10 @@ func Generate(options Options) (Result, error) { type generatedFile struct { Path string Content string + Mode os.FileMode } -func writeFile(path, content string, overwrite bool) error { +func writeFile(path, content string, mode os.FileMode, overwrite bool) error { if !overwrite { if _, err := os.Stat(path); err == nil { return fmt.Errorf("%w: %s", ErrFileExists, path) @@ -113,7 +115,11 @@ func writeFile(path, content string, overwrite bool) error { return fmt.Errorf("create scaffold directory %q: %w", dir, err) } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + if mode == 0 { + mode = 0o644 + } + + if err := os.WriteFile(path, []byte(content), mode); err != nil { return fmt.Errorf("write scaffold file %q: %w", path, err) } @@ -326,6 +332,175 @@ const goModTemplate = `module {{.ModulePath}} go 1.25.0 ` +const installTemplate = `#!/usr/bin/env bash +set -euo pipefail + +BINARY_NAME="{{.BinaryName}}" +MODULE_PATH="{{.ModulePath}}" +DEFAULT_PROFILE="{{.DefaultProfile}}" +PROFILE_ENV="{{.ProfileEnv}}" + +prompt() { + local label="$1" + local default_value="$2" + local answer="" + + if [ -n "$default_value" ]; then + printf "%s [%s]: " "$label" "$default_value" + else + printf "%s: " "$label" + fi + + if [ -r /dev/tty ]; then + IFS= read -r answer < /dev/tty || answer="" + else + IFS= read -r answer || answer="" + fi + + if [ -z "$answer" ]; then + printf "%s" "$default_value" + return + fi + + printf "%s" "$answer" +} + +go_bin_dir() { + local gobin + gobin="$(go env GOBIN 2>/dev/null || true)" + if [ -n "$gobin" ]; then + printf "%s\n" "$gobin" + return + fi + + go env GOPATH 2>/dev/null | awk '{print $1 "/bin"}' +} + +resolve_binary_path() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + command -v "$BINARY_NAME" + return + fi + + if command -v go >/dev/null 2>&1; then + local bin_dir + bin_dir="$(go_bin_dir)" + if [ -n "$bin_dir" ] && [ -x "$bin_dir/$BINARY_NAME" ]; then + printf "%s\n" "$bin_dir/$BINARY_NAME" + return + fi + fi + + printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" +} + +install_binary() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + printf "Binaire détecté: %s\n" "$(command -v "$BINARY_NAME")" + return + fi + + if ! command -v go >/dev/null 2>&1; then + printf "Go n'est pas installé. Installe Go ou utilise l'option JSON.\n" >&2 + exit 1 + fi + + printf "Installation du binaire via go install...\n" + go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest" + + local bin_dir + bin_dir="$(go_bin_dir)" + if [ -n "$bin_dir" ]; then + printf "Binaire installé dans %s\n" "$bin_dir" + printf "Ajoute ce dossier à ton PATH si nécessaire.\n" + fi +} + +run_setup_wizard() { + install_binary + + local profile + profile="$(prompt "Profil à configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")" + local binary_path + binary_path="$(resolve_binary_path)" + + printf "Lancement de %s setup...\n\n" "$BINARY_NAME" + if [ -r /dev/tty ] && [ -w /dev/tty ]; then + env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty + else + env "${PROFILE_ENV}=${profile}" "$binary_path" setup + fi +} + +print_mcp_json() { + local profile + profile="$(prompt "Profil à exposer dans la config MCP (${PROFILE_ENV})" "$DEFAULT_PROFILE")" + local default_command + default_command="$(resolve_binary_path)" + local command_path + command_path="$(prompt "Commande du serveur MCP" "$default_command")" + + cat <&2 + ;; + esac + done +} + +main "$@" +` + const mainTemplate = `package main import ( @@ -691,6 +866,7 @@ Binaire MCP généré depuis ` + "`mcp-framework`" + `. │ └── app.go ├── .gitignore ├── go.mod +├── install.sh ├── mcp.toml └── README.md ` + "```" + ` @@ -727,9 +903,16 @@ go run ./cmd/{{.BinaryName}} mcp go run ./cmd/{{.BinaryName}} config test ` + "```" + ` +6. Publier un install wizard consommable via ` + "`curl | bash`" + ` : + +` + "```bash" + ` +curl -fsSL https://///raw/branch/main/install.sh | bash +` + "```" + ` + ## Points à adapter - Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs). +- Adapter l'URL ` + "`curl .../install.sh`" + ` à votre forge/répertoire. - Compléter la logique métier dans ` + "`internal/app/app.go`" + ` (` + "`runMCP`" + `). - Ajuster les variables d’environnement connues si besoin. ` diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index c166a3b..98ef6f9 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -35,6 +35,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "README.md", "cmd/my-mcp/main.go", "go.mod", + "install.sh", "internal/app/app.go", "mcp.toml", } @@ -102,12 +103,39 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "Arborescence générée", "go run ./cmd/my-mcp setup", + "curl -fsSL https://///raw/branch/main/install.sh | bash", "internal/app/app.go", } { if !strings.Contains(string(readme), snippet) { t.Fatalf("README missing snippet %q", snippet) } } + + installScriptPath := filepath.Join(target, "install.sh") + installScript, err := os.ReadFile(installScriptPath) + if err != nil { + t.Fatalf("ReadFile install.sh: %v", err) + } + for _, snippet := range []string{ + "#!/usr/bin/env bash", + `MODULE_PATH="example.com/acme/my-mcp"`, + `go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`, + "config MCP (Codex)", + "config MCP (Claude Desktop)", + `"${PROFILE_ENV}=${profile}"`, + } { + 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) { -- 2.45.2 From b2eebf413eb0c08bcccaab4c0194247b04fb878a Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 10:21:53 +0200 Subject: [PATCH 27/58] fix(scaffold): avoid prompt capture in install wizard --- scaffold/scaffold.go | 4 ++-- scaffold/scaffold_test.go | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 01eca44..1b737a2 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -346,9 +346,9 @@ prompt() { local answer="" if [ -n "$default_value" ]; then - printf "%s [%s]: " "$label" "$default_value" + printf "%s [%s]: " "$label" "$default_value" >&2 else - printf "%s: " "$label" + printf "%s: " "$label" >&2 fi if [ -r /dev/tty ]; then diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 98ef6f9..df74d60 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -120,6 +120,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "#!/usr/bin/env bash", `MODULE_PATH="example.com/acme/my-mcp"`, `go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`, + `printf "%s [%s]: " "$label" "$default_value" >&2`, "config MCP (Codex)", "config MCP (Claude Desktop)", `"${PROFILE_ENV}=${profile}"`, -- 2.45.2 From 9d862c876f561b695911ba40ffa27ed41151f662 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 10:33:13 +0200 Subject: [PATCH 28/58] feat(scaffold): add TUI install wizard for Claude and Codex --- README.md | 2 +- scaffold/scaffold.go | 295 ++++++++++++++++++++++++++++++++------ scaffold/scaffold_test.go | 12 +- 3 files changed, 264 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 243a2e5..dad269e 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ _ = scaffoldInfo Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : - arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) -- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard setup/JSON MCP +- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP) - wiring initial `bootstrap + config + secretstore + update` - `README.md` de démarrage diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 1b737a2..852b61c 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -340,19 +340,67 @@ MODULE_PATH="{{.ModulePath}}" DEFAULT_PROFILE="{{.DefaultProfile}}" PROFILE_ENV="{{.ProfileEnv}}" +if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then + C_RESET="$(printf '\033[0m')" + C_BOLD="$(printf '\033[1m')" + C_DIM="$(printf '\033[2m')" + C_RED="$(printf '\033[31m')" + C_GREEN="$(printf '\033[32m')" + C_YELLOW="$(printf '\033[33m')" + C_BLUE="$(printf '\033[34m')" + C_MAGENTA="$(printf '\033[35m')" + C_CYAN="$(printf '\033[36m')" +else + C_RESET="" + C_BOLD="" + C_DIM="" + C_RED="" + C_GREEN="" + C_YELLOW="" + C_BLUE="" + C_MAGENTA="" + C_CYAN="" +fi + +ui_line() { + printf "%b%s%b\n" "$C_DIM" "------------------------------------------------------------" "$C_RESET" >&2 +} + +ui_title() { + printf "\n%b%s%b\n" "$C_BOLD$C_CYAN" "$1" "$C_RESET" >&2 +} + +ui_info() { + printf "%b[info]%b %s\n" "$C_BLUE" "$C_RESET" "$1" >&2 +} + +ui_success() { + printf "%b[ok]%b %s\n" "$C_GREEN" "$C_RESET" "$1" >&2 +} + +ui_warn() { + printf "%b[warn]%b %s\n" "$C_YELLOW" "$C_RESET" "$1" >&2 +} + +ui_error() { + printf "%b[error]%b %s\n" "$C_RED" "$C_RESET" "$1" >&2 +} + prompt() { local label="$1" local default_value="$2" local answer="" if [ -n "$default_value" ]; then - printf "%s [%s]: " "$label" "$default_value" >&2 + printf "%b%s%b [%s]: " "$C_BOLD" "$label" "$C_RESET" "$default_value" >&2 else - printf "%s: " "$label" >&2 + printf "%b%s%b: " "$C_BOLD" "$label" "$C_RESET" >&2 fi - if [ -r /dev/tty ]; then - IFS= read -r answer < /dev/tty || answer="" + if [ -t 2 ] && [ -r /dev/tty ]; then + if ! IFS= read -r answer < /dev/tty 2>/dev/null; then + IFS= read -r answer || answer="" + fi else IFS= read -r answer || answer="" fi @@ -365,6 +413,20 @@ prompt() { printf "%s" "$answer" } +sanitize_server_name() { + local raw="$1" + local sanitized + sanitized="$(printf "%s" "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g; s/--*/-/g; s/^-*//; s/-*$//')" + if [ -z "$sanitized" ]; then + sanitized="$BINARY_NAME" + fi + printf "%s" "$sanitized" +} + +toml_escape() { + printf "%s" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + go_bin_dir() { local gobin gobin="$(go env GOBIN 2>/dev/null || true)" @@ -394,25 +456,43 @@ resolve_binary_path() { printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" } -install_binary() { - if command -v "$BINARY_NAME" >/dev/null 2>&1; then - printf "Binaire détecté: %s\n" "$(command -v "$BINARY_NAME")" +ensure_cli() { + local cli_name="$1" + if command -v "$cli_name" >/dev/null 2>&1; then return fi + ui_error "Commande introuvable: $cli_name" + exit 1 +} + +install_binary() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + ui_success "Binaire detecte: $(command -v "$BINARY_NAME")" + local reinstall + reinstall="$(prompt "Reinstaller via go install ? (y/N)" "N")" + case "$reinstall" in + y|Y|yes|YES) + ;; + *) + return + ;; + esac + fi + if ! command -v go >/dev/null 2>&1; then - printf "Go n'est pas installé. Installe Go ou utilise l'option JSON.\n" >&2 + ui_error "Go n'est pas installe. Installe Go ou choisis une configuration manuelle." exit 1 fi - printf "Installation du binaire via go install...\n" + ui_info "Installation du binaire via go install..." go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest" local bin_dir bin_dir="$(go_bin_dir)" if [ -n "$bin_dir" ]; then - printf "Binaire installé dans %s\n" "$bin_dir" - printf "Ajoute ce dossier à ton PATH si nécessaire.\n" + ui_success "Binaire installe dans $bin_dir" + ui_info "Ajoute ce dossier au PATH si necessaire." fi } @@ -420,34 +500,161 @@ run_setup_wizard() { install_binary local profile - profile="$(prompt "Profil à configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")" + profile="$(prompt "Profil a configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")" local binary_path binary_path="$(resolve_binary_path)" - printf "Lancement de %s setup...\n\n" "$BINARY_NAME" + ui_info "Lancement de $BINARY_NAME setup" if [ -r /dev/tty ] && [ -w /dev/tty ]; then env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty else env "${PROFILE_ENV}=${profile}" "$binary_path" setup fi + ui_success "Setup termine pour le profil \"$profile\"." +} + +collect_server_inputs() { + local default_name + default_name="$(sanitize_server_name "$BINARY_NAME")" + SERVER_NAME="$(prompt "Nom du serveur MCP" "$default_name")" + SERVER_NAME="$(sanitize_server_name "$SERVER_NAME")" + + PROFILE_VALUE="$(prompt "Valeur de ${PROFILE_ENV}" "$DEFAULT_PROFILE")" + + local default_command + default_command="$(resolve_binary_path)" + COMMAND_PATH="$(prompt "Chemin du binaire serveur MCP" "$default_command")" +} + +choose_scope() { + local selected + while true; do + ui_title "Scope de configuration" + printf " 1) global (user)\n" >&2 + printf " 2) project (projet courant)\n" >&2 + selected="$(prompt "Choix" "1")" + case "$selected" in + 1) + printf "global" + return + ;; + 2) + printf "project" + return + ;; + *) + ui_warn "Choix invalide: $selected" + ;; + esac + done +} + +apply_claude_mcp() { + ensure_cli "claude" + collect_server_inputs + + local scope_choice + scope_choice="$(choose_scope)" + local claude_scope + if [ "$scope_choice" = "global" ]; then + claude_scope="user" + else + claude_scope="project" + fi + + ui_info "Application de la configuration Claude ($claude_scope)..." + claude mcp remove --scope "$claude_scope" "$SERVER_NAME" >/dev/null 2>&1 || true + claude mcp add \ + --transport stdio \ + --scope "$claude_scope" \ + -e "${PROFILE_ENV}=${PROFILE_VALUE}" \ + "$SERVER_NAME" -- "$COMMAND_PATH" mcp + + ui_success "Serveur \"$SERVER_NAME\" configure dans Claude ($claude_scope)." +} + +rewrite_codex_project_config() { + local project_dir="$1" + local config_file="$project_dir/.codex/config.toml" + local section_prefix="[mcp_servers.${SERVER_NAME}" + + mkdir -p "$project_dir/.codex" + touch "$config_file" + + local tmp_file + tmp_file="$(mktemp)" + + awk -v prefix="$section_prefix" ' + function is_target(line, p, next_char) { + if (index(line, p) != 1) { + return 0 + } + next_char = substr(line, length(p) + 1, 1) + return next_char == "]" || next_char == "." + } + /^\[.*\]$/ { + if (is_target($0, prefix)) { + skip = 1 + next + } + if (skip == 1) { + skip = 0 + } + } + { + if (skip != 1) { + print $0 + } + } + ' "$config_file" > "$tmp_file" + + mv "$tmp_file" "$config_file" + + { + printf "\n[mcp_servers.%s]\n" "$SERVER_NAME" + printf "command = \"%s\"\n" "$(toml_escape "$COMMAND_PATH")" + printf "args = [\"mcp\"]\n\n" + printf "[mcp_servers.%s.env]\n" "$SERVER_NAME" + printf "%s = \"%s\"\n" "$PROFILE_ENV" "$(toml_escape "$PROFILE_VALUE")" + } >> "$config_file" +} + +apply_codex_mcp() { + ensure_cli "codex" + collect_server_inputs + + local scope_choice + scope_choice="$(choose_scope)" + + if [ "$scope_choice" = "global" ]; then + ui_info "Application via codex mcp add (scope global)..." + codex mcp remove "$SERVER_NAME" >/dev/null 2>&1 || true + codex mcp add \ + "$SERVER_NAME" \ + --env "${PROFILE_ENV}=${PROFILE_VALUE}" \ + -- "$COMMAND_PATH" mcp + ui_success "Serveur \"$SERVER_NAME\" configure dans le scope global Codex." + return + fi + + local default_project_dir + default_project_dir="$(pwd)" + local project_dir + project_dir="$(prompt "Dossier projet cible pour .codex/config.toml" "$default_project_dir")" + rewrite_codex_project_config "$project_dir" + ui_success "Configuration projet ecrite dans $project_dir/.codex/config.toml" } print_mcp_json() { - local profile - profile="$(prompt "Profil à exposer dans la config MCP (${PROFILE_ENV})" "$DEFAULT_PROFILE")" - local default_command - default_command="$(resolve_binary_path)" - local command_path - command_path="$(prompt "Commande du serveur MCP" "$default_command")" - + collect_server_inputs cat <&2 + printf "%bFramework module:%b %s\n" "$C_DIM" "$C_RESET" "$MODULE_PATH" >&2 + ui_line + printf "Choisis une action:\n" >&2 + printf " 1) Installer/mettre a jour le binaire + setup\n" >&2 + printf " 2) Configurer Claude Code (apply direct)\n" >&2 + printf " 3) Configurer Codex (apply direct)\n" >&2 + printf " 4) Generer JSON MCP manuel\n" >&2 + printf " 5) Quitter\n" >&2 } main() { @@ -474,25 +680,32 @@ main() { print_header local choice choice="$(prompt "Choix" "1")" - printf "\n" + printf "\n" >&2 case "$choice" in 1) run_setup_wizard - printf "\nInstallation terminée.\n" return ;; - 2|3|4) - printf "Copie ce JSON dans la config MCP du client ciblé.\n\n" + 2) + apply_claude_mcp + return + ;; + 3) + apply_codex_mcp + return + ;; + 4) + ui_info "JSON MCP genere sur stdout." print_mcp_json return ;; 5) - printf "Annulé.\n" + ui_warn "Annule." return ;; *) - printf "Choix invalide: %s\n\n" "$choice" >&2 + ui_warn "Choix invalide: $choice" ;; esac done @@ -909,6 +1122,8 @@ go run ./cmd/{{.BinaryName}} config test curl -fsSL https://///raw/branch/main/install.sh | bash ` + "```" + ` +Le wizard permet ensuite d'appliquer directement la configuration MCP pour Claude Code ou Codex (scope global/projet), ou de générer un JSON manuel. + ## Points à adapter - Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs). diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index df74d60..5898c7a 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -120,10 +120,14 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "#!/usr/bin/env bash", `MODULE_PATH="example.com/acme/my-mcp"`, `go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`, - `printf "%s [%s]: " "$label" "$default_value" >&2`, - "config MCP (Codex)", - "config MCP (Claude Desktop)", - `"${PROFILE_ENV}=${profile}"`, + "MCP Install Wizard", + `claude mcp add \`, + `--transport stdio \`, + `--scope "$claude_scope" \`, + `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) -- 2.45.2 From 3eeb2fe17358f5777039f43fcc53e7ba44a773cf Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 11:29:45 +0200 Subject: [PATCH 29/58] feat(scaffold): align generated install wizard with latest TUI flow --- scaffold/scaffold.go | 244 ++++++++++++++++++++++++++++---------- scaffold/scaffold_test.go | 4 + 2 files changed, 185 insertions(+), 63 deletions(-) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 852b61c..918fdc9 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -339,6 +339,9 @@ BINARY_NAME="{{.BinaryName}}" MODULE_PATH="{{.ModulePath}}" DEFAULT_PROFILE="{{.DefaultProfile}}" PROFILE_ENV="{{.ProfileEnv}}" +PREFILL_SERVER_NAME="" +PREFILL_PROFILE_VALUE="" +PREFILL_COMMAND_PATH="" if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then C_RESET="$(printf '\033[0m')" @@ -413,6 +416,101 @@ prompt() { printf "%s" "$answer" } +tty_prompt_available() { + [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ] +} + +menu_select() { + local title="$1" + shift + local options=("$@") + local count="${#options[@]}" + local index=0 + local key="" + local i=0 + local rows=$((count + 3)) + local rendered=0 + + if [ "$count" -eq 0 ]; then + return 1 + fi + + if ! tty_prompt_available; then + ui_title "$title" + i=0 + while [ "$i" -lt "$count" ]; do + printf " %d) %s\n" "$((i + 1))" "${options[$i]}" >&2 + i=$((i + 1)) + done + + while true; do + local raw_choice + raw_choice="$(prompt "Choix" "1")" + case "$raw_choice" in + ''|*[!0-9]*) + ui_warn "Choix invalide: $raw_choice" + ;; + *) + if [ "$raw_choice" -ge 1 ] && [ "$raw_choice" -le "$count" ]; then + printf "%s" "${options[$((raw_choice - 1))]}" + return 0 + fi + ui_warn "Choix invalide: $raw_choice" + ;; + esac + done + fi + + while true; do + if [ "$rendered" -eq 1 ]; then + printf "\033[%dA\033[J" "$rows" >&2 2>/dev/null || true + fi + ui_title "$title" + i=0 + while [ "$i" -lt "$count" ]; do + if [ "$i" -eq "$index" ]; then + printf " %b› %s%b\n" "$C_BOLD$C_CYAN" "${options[$i]}" "$C_RESET" >&2 + else + printf " %s\n" "${options[$i]}" >&2 + fi + i=$((i + 1)) + done + printf "%bUtilise ↑/↓ puis Entrée.%b\n" "$C_DIM" "$C_RESET" >&2 + rendered=1 + + if ! IFS= read -rsn1 key < /dev/tty; then + continue + fi + + case "$key" in + "") + printf "%s" "${options[$index]}" + return 0 + ;; + $'\x1b') + if IFS= read -rsn2 key < /dev/tty; then + case "$key" in + "[A") + if [ "$index" -eq 0 ]; then + index=$((count - 1)) + else + index=$((index - 1)) + fi + ;; + "[B") + if [ "$index" -eq $((count - 1)) ]; then + index=0 + else + index=$((index + 1)) + fi + ;; + esac + fi + ;; + esac + done +} + sanitize_server_name() { local raw="$1" local sanitized @@ -505,48 +603,46 @@ run_setup_wizard() { binary_path="$(resolve_binary_path)" ui_info "Lancement de $BINARY_NAME setup" - if [ -r /dev/tty ] && [ -w /dev/tty ]; then + if [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty else env "${PROFILE_ENV}=${profile}" "$binary_path" setup fi ui_success "Setup termine pour le profil \"$profile\"." + + PREFILL_SERVER_NAME="$(sanitize_server_name "$BINARY_NAME")" + PREFILL_PROFILE_VALUE="$profile" + PREFILL_COMMAND_PATH="$binary_path" } collect_server_inputs() { local default_name - default_name="$(sanitize_server_name "$BINARY_NAME")" + default_name="$(sanitize_server_name "${PREFILL_SERVER_NAME:-$BINARY_NAME}")" SERVER_NAME="$(prompt "Nom du serveur MCP" "$default_name")" SERVER_NAME="$(sanitize_server_name "$SERVER_NAME")" - PROFILE_VALUE="$(prompt "Valeur de ${PROFILE_ENV}" "$DEFAULT_PROFILE")" + PROFILE_VALUE="$(prompt "Valeur de ${PROFILE_ENV}" "${PREFILL_PROFILE_VALUE:-$DEFAULT_PROFILE}")" local default_command - default_command="$(resolve_binary_path)" + if [ -n "${PREFILL_COMMAND_PATH:-}" ]; then + default_command="$PREFILL_COMMAND_PATH" + else + default_command="$(resolve_binary_path)" + fi COMMAND_PATH="$(prompt "Chemin du binaire serveur MCP" "$default_command")" } choose_scope() { local selected - while true; do - ui_title "Scope de configuration" - printf " 1) global (user)\n" >&2 - printf " 2) project (projet courant)\n" >&2 - selected="$(prompt "Choix" "1")" - case "$selected" in - 1) - printf "global" - return - ;; - 2) - printf "project" - return - ;; - *) - ui_warn "Choix invalide: $selected" - ;; - esac - done + selected="$(menu_select "Scope de configuration" "global (user)" "project (projet courant)")" + case "$selected" in + "global (user)") + printf "global" + ;; + *) + printf "project" + ;; + esac } apply_claude_mcp() { @@ -567,8 +663,9 @@ apply_claude_mcp() { claude mcp add \ --transport stdio \ --scope "$claude_scope" \ - -e "${PROFILE_ENV}=${PROFILE_VALUE}" \ - "$SERVER_NAME" -- "$COMMAND_PATH" mcp + "$SERVER_NAME" \ + --env "${PROFILE_ENV}=${PROFILE_VALUE}" \ + -- "$COMMAND_PATH" mcp ui_success "Serveur \"$SERVER_NAME\" configure dans Claude ($claude_scope)." } @@ -667,48 +764,69 @@ print_header() { printf "%bMCP Install Wizard%b for %b%s%b\n" "$C_BOLD$C_MAGENTA" "$C_RESET" "$C_BOLD" "$BINARY_NAME" "$C_RESET" >&2 printf "%bFramework module:%b %s\n" "$C_DIM" "$C_RESET" "$MODULE_PATH" >&2 ui_line - printf "Choisis une action:\n" >&2 - printf " 1) Installer/mettre a jour le binaire + setup\n" >&2 - printf " 2) Configurer Claude Code (apply direct)\n" >&2 - printf " 3) Configurer Codex (apply direct)\n" >&2 - printf " 4) Generer JSON MCP manuel\n" >&2 - printf " 5) Quitter\n" >&2 + printf "%bSelectionne une action dans le menu interactif.%b\n" "$C_DIM" "$C_RESET" >&2 +} + +post_setup_configure_mcp() { + ui_title "Configuration MCP apres setup" + local next_action + next_action="$(menu_select \ + "Configurer le MCP maintenant ?" \ + "Configurer Claude Code (apply direct)" \ + "Configurer Codex (apply direct)" \ + "Generer JSON MCP manuel" \ + "Terminer sans config MCP")" + printf "\n" >&2 + + case "$next_action" in + "Configurer Claude Code (apply direct)") + apply_claude_mcp + ;; + "Configurer Codex (apply direct)") + apply_codex_mcp + ;; + "Generer JSON MCP manuel") + ui_info "JSON MCP genere sur stdout." + print_mcp_json + ;; + *) + ui_info "Setup termine sans configuration MCP additionnelle." + ;; + esac } main() { - while true; do - print_header - local choice - choice="$(prompt "Choix" "1")" - printf "\n" >&2 + print_header - case "$choice" in - 1) - run_setup_wizard - return - ;; - 2) - apply_claude_mcp - return - ;; - 3) - apply_codex_mcp - return - ;; - 4) - ui_info "JSON MCP genere sur stdout." - print_mcp_json - return - ;; - 5) - ui_warn "Annule." - return - ;; - *) - ui_warn "Choix invalide: $choice" - ;; - esac - done + local action + action="$(menu_select \ + "Choisis une action" \ + "Installer/mettre a jour le binaire + setup" \ + "Configurer Claude Code (apply direct)" \ + "Configurer Codex (apply direct)" \ + "Generer JSON MCP manuel" \ + "Quitter")" + printf "\n" >&2 + + case "$action" in + "Installer/mettre a jour le binaire + setup") + run_setup_wizard + post_setup_configure_mcp + ;; + "Configurer Claude Code (apply direct)") + apply_claude_mcp + ;; + "Configurer Codex (apply direct)") + apply_codex_mcp + ;; + "Generer JSON MCP manuel") + ui_info "JSON MCP genere sur stdout." + print_mcp_json + ;; + *) + ui_warn "Annule." + ;; + esac } main "$@" diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 5898c7a..6749caa 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -121,9 +121,13 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { `MODULE_PATH="example.com/acme/my-mcp"`, `go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`, "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]`, -- 2.45.2 From 0d266cd5cc4210d74789b9c6177d8155a0cc7fe5 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 12:13:41 +0200 Subject: [PATCH 30/58] =?UTF-8?q?fix:=20durcir=20le=20scaffold=20runtime?= =?UTF-8?q?=20et=20la=20s=C3=A9curit=C3=A9=20des=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 25 ++++++++++++++++++ scaffold/scaffold.go | 55 ++++++++++++++++++++++++++------------- scaffold/scaffold_test.go | 3 +++ update/update.go | 34 +++++++++++++++++++++--- update/update_test.go | 39 +++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..11be548 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 ./... diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 918fdc9..c91ac45 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -70,19 +70,24 @@ func Generate(options Options) (Result, error) { } files := []generatedFile{ - {Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized), Mode: 0o644}, - {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized), Mode: 0o644}, - {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized), Mode: 0o644}, - {Path: "install.sh", Content: renderTemplate(installTemplate, normalized), Mode: 0o755}, - {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized), Mode: 0o644}, - {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized), Mode: 0o644}, - {Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized), Mode: 0o644}, + {Path: ".gitignore", Template: gitignoreTemplate, Mode: 0o644}, + {Path: "go.mod", Template: goModTemplate, Mode: 0o644}, + {Path: "README.md", Template: readmeTemplate, Mode: 0o644}, + {Path: "install.sh", Template: installTemplate, Mode: 0o755}, + {Path: "mcp.toml", Template: manifestTemplate, Mode: 0o644}, + {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Template: mainTemplate, Mode: 0o644}, + {Path: filepath.Join("internal", "app", "app.go"), Template: appTemplate, Mode: 0o644}, } written := make([]string, 0, len(files)) for _, file := range files { + content, err := renderTemplate(file.Template, normalized) + if err != nil { + return Result{}, fmt.Errorf("render scaffold file %q: %w", file.Path, err) + } + fullPath := filepath.Join(normalized.TargetDir, file.Path) - if err := writeFile(fullPath, file.Content, file.Mode, normalized.Overwrite); err != nil { + if err := writeFile(fullPath, content, file.Mode, normalized.Overwrite); err != nil { return Result{}, err } written = append(written, file.Path) @@ -96,9 +101,9 @@ func Generate(options Options) (Result, error) { } type generatedFile struct { - Path string - Content string - Mode os.FileMode + Path string + Template string + Mode os.FileMode } func writeFile(path, content string, mode os.FileMode, overwrite bool) error { @@ -126,15 +131,18 @@ func writeFile(path, content string, mode os.FileMode, overwrite bool) error { return nil } -func renderTemplate(src string, data normalizedOptions) string { - tpl := template.Must(template.New("scaffold").Parse(src)) +func renderTemplate(src string, data normalizedOptions) (string, error) { + tpl, err := template.New("scaffold").Parse(src) + if err != nil { + return "", fmt.Errorf("parse template: %w", err) + } var builder strings.Builder if err := tpl.Execute(&builder, data); err != nil { - panic(err) + return "", fmt.Errorf("execute template: %w", err) } - return builder.String() + return builder.String(), nil } func normalizeOptions(options Options) (normalizedOptions, error) { @@ -859,6 +867,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" "gitea.lclr.dev/AI/mcp-framework/bootstrap" @@ -895,9 +904,19 @@ func Run(ctx context.Context, args []string, version string) error { } func NewRuntime(version string) (Runtime, error) { - manifestFile, _, err := manifest.LoadDefault(".") + manifestStartDir := "." + if executablePath, err := os.Executable(); err == nil { + if dir := strings.TrimSpace(filepath.Dir(executablePath)); dir != "" { + manifestStartDir = dir + } + } + + manifestFile, _, err := manifest.LoadDefault(manifestStartDir) if err != nil { - return Runtime{}, err + if !errors.Is(err, os.ErrNotExist) { + return Runtime{}, err + } + manifestFile = manifest.File{} } bootstrapInfo := manifestFile.BootstrapInfo() @@ -1162,7 +1181,7 @@ repository = "{{.ReleaseRepository}}" base_url = "{{.ReleaseBaseURL}}" asset_name_template = "{binary}-{os}-{arch}{ext}" checksum_asset_name = "{asset}.sha256" -checksum_required = false +checksum_required = true token_header = "Authorization" token_prefix = "token" token_env_names = ["{{.ReleaseTokenEnv}}"] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 6749caa..bf72730 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -70,6 +70,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "update.Run", "manifest.LoadDefault", "bootstrap.Run", + "os.Executable()", + "errors.Is(err, os.ErrNotExist)", } { if !strings.Contains(string(appGo), snippet) { t.Fatalf("app.go missing snippet %q", snippet) @@ -86,6 +88,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "binary_name = \"my-mcp\"", "[update]", + "checksum_required = true", "[secret_store]", "[environment]", "[profiles]", diff --git a/update/update.go b/update/update.go index 7ba393a..6c5dbfb 100644 --- a/update/update.go +++ b/update/update.go @@ -20,6 +20,7 @@ import ( ) const defaultAssetNameTemplate = "{binary}-{os}-{arch}{ext}" +const defaultMaxDownloadBytes int64 = 200 * 1024 * 1024 type Options struct { Client *http.Client @@ -32,6 +33,7 @@ type Options struct { ReleaseSource ReleaseSource GOOS string GOARCH string + MaxDownloadBytes int64 ValidateDownloaded ValidateDownloadedFunc ReplaceExecutable ReplaceExecutableFunc } @@ -124,6 +126,9 @@ 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) @@ -162,7 +167,7 @@ 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 } @@ -394,7 +399,18 @@ func (r Release) AssetURL(assetName, releaseURL string) (string, error) { ) } -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) @@ -419,6 +435,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 { @@ -437,9 +460,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)) } diff --git a/update/update_test.go b/update/update_test.go index ab427e3..9c3f98d 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -384,6 +384,45 @@ func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) return fn(req) } +func TestDownloadReleaseAssetRejectsArtifactOverLimit(t *testing.T) { + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.String() != "https://releases.example.com/artifact" { + t.Fatalf("unexpected url: %s", r.URL.String()) + } + body := "123456" + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + ContentLength: int64(len(body)), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + _, err := DownloadReleaseAsset( + context.Background(), + client, + "https://releases.example.com/artifact", + target, + Auth{}, + ReleaseSource{}, + 5, + ) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "exceeds limit") { + t.Fatalf("error = %v", err) + } +} + func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("self-replace is not supported on windows") -- 2.45.2 From fbff660bcc8a3a9c34539e0ab6f6b166b1f22de6 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 12:20:06 +0200 Subject: [PATCH 31/58] =?UTF-8?q?feat:=20ajouter=20la=20v=C3=A9rification?= =?UTF-8?q?=20Ed25519=20des=20artefacts=20de=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +- cmd/mcp-framework/main.go | 5 +- manifest/manifest.go | 55 +++++---- manifest/manifest_test.go | 16 +++ scaffold/scaffold.go | 13 ++ scaffold/scaffold_test.go | 2 + update/update.go | 234 ++++++++++++++++++++++++++++++++++-- update/update_test.go | 247 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 545 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index dad269e..663088e 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,10 @@ repository = "org/repo" base_url = "https://gitea.example.com" asset_name_template = "{binary}-{os}-{arch}{ext}" checksum_asset_name = "{asset}.sha256" -checksum_required = false +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"] @@ -159,6 +162,10 @@ Champs supportés : - `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. diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go index d6f98b9..a4a263f 100644 --- a/cmd/mcp-framework/main.go +++ b/cmd/mcp-framework/main.go @@ -77,6 +77,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error { 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)") @@ -92,6 +93,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error { 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 { @@ -121,6 +123,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error { ReleaseBaseURL: releaseBaseURL, ReleaseRepository: releaseRepository, ReleaseTokenEnv: releaseTokenEnv, + ReleasePublicKeyEnv: releasePublicKeyEnv, Overwrite: overwrite, }) if err != nil { @@ -159,7 +162,7 @@ func printScaffoldHelp(w io.Writer) { func printScaffoldInitHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s scaffold init --target [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --overwrite Écraser les fichiers existants\n", + "Usage:\n %s scaffold init --target [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --release-pubkey-env Variable cle publique Ed25519 release\n --overwrite Écraser les fichiers existants\n", toolName, ) } diff --git a/manifest/manifest.go b/manifest/manifest.go index b3c9414..544e59a 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -25,17 +25,21 @@ type File struct { } type Update struct { - SourceName string `toml:"source_name"` - Driver string `toml:"driver"` - Repository string `toml:"repository"` - BaseURL string `toml:"base_url"` - LatestReleaseURL string `toml:"latest_release_url"` - AssetNameTemplate string `toml:"asset_name_template"` - ChecksumAssetName string `toml:"checksum_asset_name"` - ChecksumRequired bool `toml:"checksum_required"` - TokenHeader string `toml:"token_header"` - TokenPrefix string `toml:"token_prefix"` - 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 { @@ -155,6 +159,9 @@ func (u *Update) normalize() { 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) @@ -181,17 +188,21 @@ func (u Update) ReleaseSource() update.ReleaseSource { u.normalize() return update.ReleaseSource{ - Name: u.SourceName, - Driver: u.Driver, - Repository: u.Repository, - BaseURL: u.BaseURL, - LatestReleaseURL: u.LatestReleaseURL, - AssetNameTemplate: u.AssetNameTemplate, - ChecksumAssetName: u.ChecksumAssetName, - ChecksumRequired: u.ChecksumRequired, - TokenHeader: u.TokenHeader, - TokenPrefix: u.TokenPrefix, - 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...), } } diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 83ea7d5..5d6a739 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -53,6 +53,10 @@ 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"] @@ -92,6 +96,18 @@ token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"] 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) } diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index c91ac45..81e9fe7 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -31,6 +31,7 @@ type Options struct { ReleaseBaseURL string ReleaseRepository string ReleaseTokenEnv string + ReleasePublicKeyEnv string Overwrite bool } @@ -56,6 +57,7 @@ type normalizedOptions struct { ReleaseBaseURL string ReleaseRepository string ReleaseTokenEnv string + ReleasePublicKeyEnv string Overwrite bool } @@ -231,6 +233,13 @@ func normalizeOptions(options Options) (normalizedOptions, error) { if releaseTokenEnv == "" { releaseTokenEnv = envPrefix + "_RELEASE_TOKEN" } + releasePublicKeyEnv := strings.TrimSpace(options.ReleasePublicKeyEnv) + if releasePublicKeyEnv == "" { + releasePublicKeyEnv = envPrefix + "_RELEASE_ED25519_PUBLIC_KEY" + } + if !slices.Contains(knownEnvironmentVariables, releasePublicKeyEnv) { + knownEnvironmentVariables = append(knownEnvironmentVariables, releasePublicKeyEnv) + } return normalizedOptions{ TargetDir: resolvedTarget, @@ -249,6 +258,7 @@ func normalizeOptions(options Options) (normalizedOptions, error) { ReleaseBaseURL: releaseBaseURL, ReleaseRepository: releaseRepository, ReleaseTokenEnv: releaseTokenEnv, + ReleasePublicKeyEnv: releasePublicKeyEnv, Overwrite: options.Overwrite, }, nil } @@ -1182,6 +1192,9 @@ base_url = "{{.ReleaseBaseURL}}" asset_name_template = "{binary}-{os}-{arch}{ext}" checksum_asset_name = "{asset}.sha256" checksum_required = true +signature_asset_name = "{asset}.sig" +signature_required = false +signature_public_key_env_names = ["{{.ReleasePublicKeyEnv}}"] token_header = "Authorization" token_prefix = "token" token_env_names = ["{{.ReleaseTokenEnv}}"] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index bf72730..be726db 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -89,6 +89,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "binary_name = \"my-mcp\"", "[update]", "checksum_required = true", + "signature_asset_name = \"{asset}.sig\"", + "signature_required = false", "[secret_store]", "[environment]", "[profiles]", diff --git a/update/update.go b/update/update.go index 6c5dbfb..21aa789 100644 --- a/update/update.go +++ b/update/update.go @@ -2,7 +2,9 @@ package update import ( "context" + "crypto/ed25519" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -52,18 +54,22 @@ type ValidationInput struct { } type ReleaseSource struct { - Name string - Driver string - Repository string - BaseURL string - LatestReleaseURL string - AssetNameTemplate string - ChecksumAssetName string - ChecksumRequired bool - Token string - TokenHeader string - TokenPrefix 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 { @@ -176,6 +182,9 @@ func Run(ctx context.Context, opts Options) error { 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{ @@ -527,6 +536,70 @@ func VerifyReleaseAssetChecksum( return nil } +func VerifyReleaseAssetSignature( + ctx context.Context, + client *http.Client, + release Release, + releaseURL string, + assetName string, + artifactPath string, + auth Auth, + source ReleaseSource, +) error { + source = normalizeSource(source) + + publicKey, hasPublicKey, err := resolveEd25519PublicKey(source.SignaturePublicKey, source.SignaturePublicKeyEnvNames) + if err != nil { + return fmt.Errorf("signature verification: %w", err) + } + if !hasPublicKey { + if source.SignatureRequired { + if len(source.SignaturePublicKeyEnvNames) > 0 { + return fmt.Errorf( + "signature verification: no Ed25519 public key configured (set %s)", + strings.Join(source.SignaturePublicKeyEnvNames, " or "), + ) + } + return errors.New("signature verification: no Ed25519 public key configured") + } + return nil + } + + signatureAssetName := resolveSignatureAssetName(assetName, source.SignatureAssetName) + signatureURL, err := release.AssetURL(signatureAssetName, releaseURL) + if err != nil { + if source.SignatureRequired { + return fmt.Errorf("signature verification: %w", err) + } + return nil + } + + signatureBody, err := downloadAssetBytes(ctx, client, signatureURL, auth, source) + if err != nil { + return fmt.Errorf("signature verification: %w", err) + } + + signature, err := parseEd25519Signature(string(signatureBody), assetName) + if err != nil { + return fmt.Errorf("signature verification: %w", err) + } + + digestHex, err := fileSHA256(artifactPath) + if err != nil { + return fmt.Errorf("signature verification: %w", err) + } + digest, err := hex.DecodeString(digestHex) + if err != nil { + return fmt.Errorf("signature verification: decode local artifact digest: %w", err) + } + + if !ed25519.Verify(publicKey, digest, signature) { + return fmt.Errorf("signature mismatch for asset %q", assetName) + } + + return nil +} + func ReplaceExecutable(downloadPath, targetPath string) error { if runtime.GOOS == "windows" { return errors.New("self-update is not supported on windows without a custom ReplaceExecutable hook") @@ -545,6 +618,8 @@ func normalizeSource(source ReleaseSource) ReleaseSource { 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) @@ -557,6 +632,14 @@ func normalizeSource(source ReleaseSource) ReleaseSource { } 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 == "" { @@ -742,6 +825,14 @@ func resolveChecksumAssetName(assetName, configured string) string { 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 { @@ -835,6 +926,125 @@ func parseChecksum(content, assetName string) (string, error) { 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, "./") diff --git a/update/update_test.go b/update/update_test.go index 9c3f98d..dcc0c81 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -3,6 +3,8 @@ package update import ( "bytes" "context" + "crypto/ed25519" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" @@ -674,6 +676,251 @@ func TestRunVerifiesChecksumWhenSidecarAvailable(t *testing.T) { } } +func TestRunVerifiesEd25519SignatureWhenConfigured(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + const newBinary = "new-binary" + digest := sha256.Sum256([]byte(newBinary)) + signature := ed25519.Sign(privateKey, digest[:]) + signatureBody := hex.EncodeToString(signature) + " " + assetName + "\n" + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + {Name: assetName + ".sig", URL: "https://releases.example.com/artifact.sig"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(newBinary)), + }, nil + case "https://releases.example.com/artifact.sig": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(signatureBody)), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + t.Setenv("RELEASE_PUBKEY", hex.EncodeToString(publicKey)) + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReleaseSource: ReleaseSource{ + SignatureRequired: true, + SignaturePublicKeyEnvNames: []string{"RELEASE_PUBKEY"}, + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestRunFailsOnEd25519SignatureMismatch(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + signature := ed25519.Sign(privateKey, []byte("wrong-digest")) + signatureBody := hex.EncodeToString(signature) + " " + assetName + "\n" + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + {Name: assetName + ".sig", URL: "https://releases.example.com/artifact.sig"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + case "https://releases.example.com/artifact.sig": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(signatureBody)), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + t.Setenv("RELEASE_PUBKEY", hex.EncodeToString(publicKey)) + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReleaseSource: ReleaseSource{ + SignatureRequired: true, + SignaturePublicKeyEnvNames: []string{"RELEASE_PUBKEY"}, + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "signature mismatch") { + t.Fatalf("error = %v", err) + } +} + +func TestRunFailsWhenSignatureRequiredAndPublicKeyMissing(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReleaseSource: ReleaseSource{ + SignatureRequired: true, + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(strings.ToLower(err.Error()), "public key") { + t.Fatalf("error = %v", err) + } +} + func TestRunFailsOnChecksumMismatch(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { -- 2.45.2 From f0e2e9304b21e82bbd26419bc7125e7e6c5e6251 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 14:06:28 +0200 Subject: [PATCH 32/58] docs: reorganize README and split detailed documentation --- README.md | 654 ++-------------------------------------- docs/README.md | 17 ++ docs/auto-update.md | 51 ++++ docs/bootstrap-cli.md | 45 +++ docs/cli-helpers.md | 187 ++++++++++++ docs/config.md | 64 ++++ docs/getting-started.md | 40 +++ docs/limitations.md | 3 + docs/manifest.md | 83 +++++ docs/minimal-example.md | 36 +++ docs/packages.md | 9 + docs/scaffolding.md | 26 ++ docs/secrets.md | 89 ++++++ 13 files changed, 674 insertions(+), 630 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/auto-update.md create mode 100644 docs/bootstrap-cli.md create mode 100644 docs/cli-helpers.md create mode 100644 docs/config.md create mode 100644 docs/getting-started.md create mode 100644 docs/limitations.md create mode 100644 docs/manifest.md create mode 100644 docs/minimal-example.md create mode 100644 docs/packages.md create mode 100644 docs/scaffolding.md create mode 100644 docs/secrets.md diff --git a/README.md b/README.md index 663088e..e9b682d 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,23 @@ # mcp-framework -Bibliothèque Go pour construire des binaires MCP avec : +`mcp-framework` est une bibliothèque Go pour construire des binaires MCP robustes, sans imposer un runtime lourd. -- 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 principal à savoir -Le framework est volontairement petit. Il fournit des briques réutilisables, -pas une application MCP complète. +- Le framework fournit des briques réutilisables : config locale, secrets, résolution CLI, manifeste projet, et auto-update. +- Il peut être utilisé de manière modulaire (package par package) ou avec un bootstrap CLI prêt à l'emploi. +- Il inclut un générateur de squelette (`mcp-framework scaffold init`) pour démarrer un nouveau binaire MCP rapidement. +- Toute la documentation détaillée est maintenant organisée dans `docs/` par grandes parties. -## Installation +## Démarrage rapide + +Installer le framework dans un projet Go existant : ```bash go get gitea.lclr.dev/AI/mcp-framework ``` -## CLI de scaffold - -Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go : +Initialiser un nouveau projet MCP depuis un dossier vide : ```bash go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest @@ -38,621 +36,17 @@ go mod tidy go run ./cmd/my-mcp help ``` -## Packages - -- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `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, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. -- `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 helper runtime `OpenFromManifest`. -- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. - -## Utilisation type - -Le flux typique côté application est : - -1. Déclarer les sous-commandes communes via `bootstrap` (optionnel). -2. Résoudre le profil actif avec `cli`. -3. Charger la config versionnée avec `config`. -4. Lire les secrets avec `secretstore`. -5. Charger `mcp.toml` avec `manifest`. -6. Exécuter l'auto-update avec `update` si nécessaire. -7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. - -## Bootstrap CLI - -Le package `bootstrap` reste optionnel : une application peut l'utiliser pour -uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique. - -Exemple minimal : - -```go -func main() { - err := bootstrap.Run(context.Background(), bootstrap.Options{ - BinaryName: "my-mcp", - Description: "Client MCP", - Version: version, - EnableDoctorAlias: true, // expose `doctor` comme alias de `config test` - AliasDescriptions: map[string]string{ - "doctor": "Diagnostiquer la configuration locale.", - }, - Hooks: bootstrap.Hooks{ - Setup: func(ctx context.Context, inv bootstrap.Invocation) error { - return runSetup(ctx, inv.Args) - }, - MCP: func(ctx context.Context, inv bootstrap.Invocation) error { - return runMCP(ctx, inv.Args) - }, - ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error { - return runConfigShow(ctx, inv.Args) - }, - ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error { - return runConfigTest(ctx, inv.Args) - }, - Update: func(ctx context.Context, inv bootstrap.Invocation) error { - return runUpdate(ctx, inv.Args) - }, - }, - }) - if err != nil { - log.Fatal(err) - } -} -``` - -Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche -automatiquement `Options.Version`. - -Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). -La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). -Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. - -## 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 -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" - -[profiles] -default = "prod" -known = ["dev", "staging", "prod"] - -[bootstrap] -description = "Client MCP interne" -``` - -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`). -- `[profiles].default` : profil recommandé par défaut. -- `[profiles].known` : profils connus du projet. -- `[bootstrap].description` : description CLI utilisée par le bootstrap. - -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 -``` - -## Scaffolding - -Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : - -- arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) -- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP) -- wiring initial `bootstrap + config + secretstore + update` -- `README.md` de démarrage - -Exemple : - -```go -result, err := scaffold.Generate(scaffold.Options{ - TargetDir: "./my-mcp", - ModulePath: "gitea.lclr.dev/AI/my-mcp", - BinaryName: "my-mcp", - Description: "Client MCP interne", - DefaultProfile: "prod", - Profiles: []string{"dev", "prod"}, -}) -if err != nil { - return err -} - -fmt.Printf("Scaffold generated in %s (%d files)\n", result.Root, len(result.Files)) -``` - -## 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 - }, -}) -``` - -## 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 - -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: "email-mcp", - BackendPolicy: secretstore.BackendKWalletOnly, -}) -``` - -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 -} -``` - -En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`. -Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`. - -## Helpers CLI - -`cli` fournit des helpers simples pour les assistants interactifs : - -```go -profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) - -baseURL, err := cli.PromptLine(reader, os.Stdout, "Base URL", profile.BaseURL) -if err != nil { - return err -} -if err := cli.ValidateBaseURL(baseURL); err != nil { - return err -} - -token, err := cli.PromptSecret(os.Stdin, os.Stdout, "API token", hasStoredSecret, storedToken) -if err != nil { - return err -} -``` - -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, // conserve la valeur existante si l'utilisateur laisse vide - }, - { - 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.") -} -``` - -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), // ErrNotFound => valeur absente, pas une erreur -}) - -resolution, err := cli.ResolveFields(cli.ResolveOptions{ - Fields: []cli.FieldSpec{ - {Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"}, - {Name: "api_token", Required: true, EnvKey: "MY_MCP_API_TOKEN", SecretKey: "my-mcp-api-token"}, - {Name: "timeout", DefaultValue: "30s"}, - }, - Lookup: lookup, -}) -if err != nil { - return err -} - -if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil { - return err -} -``` - -`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et -`FieldSpec.Sources` permet de définir un ordre spécifique pour un champ. - -Le package fournit aussi un socle réutilisable pour une commande `doctor`. -L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), -mais peut réutiliser les checks communs et ajouter ses propres hooks : - -```go -report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ - ConfigCheck: cli.NewConfigCheck(store), - SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) { - return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ - ServiceName: "my-mcp", - }) - }), - RequiredSecrets: []cli.DoctorSecret{ - {Name: "api-token", Label: "API token"}, - }, - SecretStoreFactory: func() (secretstore.Store, error) { - return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ - ServiceName: "my-mcp", - }) - }, - ManifestDir: ".", - ConnectivityCheck: func(context.Context) cli.DoctorResult { - if err := pingBackend(); err != nil { - return cli.DoctorResult{ - Name: "connectivity", - Status: cli.DoctorStatusFail, - Summary: "backend is unreachable", - Detail: err.Error(), - } - } - return cli.DoctorResult{ - Name: "connectivity", - Status: cli.DoctorStatusOK, - Summary: "backend is reachable", - } - }, -}) - -if err := cli.RenderDoctorReport(os.Stdout, report); err != nil { - return err -} - -if report.HasFailures() { - os.Exit(1) -} -``` - -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), // provenance explicite: env ou secret - }), - }), - }, -}) -``` - -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. - -## 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 actuellement : - -```json -{ - "tag_name": "v1.2.3", - "assets": { - "links": [ - { - "name": "my-mcp-linux-amd64", - "url": "https://example.com/downloads/my-mcp-linux-amd64" - } - ] - } -} -``` - -Exemple : - -```go -file, _, err := manifest.LoadDefault(".") -if err != nil { - return err -} - -err = update.Run(ctx, update.Options{ - CurrentVersion: version, - BinaryName: "my-mcp", - ReleaseSource: file.Update.ReleaseSource(), - Stdout: os.Stdout, -}) -if err != nil { - return err -} -``` - -Comportement : - -- le nom de l'asset est configurable (`asset_name_template`) et supporte tout couple `GOOS/GOARCH` -- si un asset `.sha256` (ou `checksum_asset_name`) existe, le binaire téléchargé est vérifié avant remplacement -- un hook `ValidateDownloaded` permet d'ajouter une validation custom (signature, scan, etc.) -- sur Windows, le remplacement in-place n'est pas fait par défaut ; fournir `Options.ReplaceExecutable` pour une stratégie dédiée - -## 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 - -- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes +## Documentation + +- Vue d'ensemble : [docs/README.md](docs/README.md) +- Installation et usage type : [docs/getting-started.md](docs/getting-started.md) +- Packages : [docs/packages.md](docs/packages.md) +- Bootstrap CLI : [docs/bootstrap-cli.md](docs/bootstrap-cli.md) +- Manifeste `mcp.toml` : [docs/manifest.md](docs/manifest.md) +- Scaffolding : [docs/scaffolding.md](docs/scaffolding.md) +- Config JSON : [docs/config.md](docs/config.md) +- Secrets : [docs/secrets.md](docs/secrets.md) +- Helpers CLI : [docs/cli-helpers.md](docs/cli-helpers.md) +- Auto-update : [docs/auto-update.md](docs/auto-update.md) +- Exemple minimal : [docs/minimal-example.md](docs/minimal-example.md) +- Limites actuelles : [docs/limitations.md](docs/limitations.md) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..83fedb6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# Documentation mcp-framework + +Cette documentation est organisée par grandes parties pour séparer la vue d'ensemble des détails d'implémentation. + +## Navigation + +- [Installation et utilisation type](getting-started.md) +- [Packages](packages.md) +- [Bootstrap CLI](bootstrap-cli.md) +- [Manifeste `mcp.toml`](manifest.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 actuelles](limitations.md) diff --git a/docs/auto-update.md b/docs/auto-update.md new file mode 100644 index 0000000..afce8e1 --- /dev/null +++ b/docs/auto-update.md @@ -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 actuellement : + +```json +{ + "tag_name": "v1.2.3", + "assets": { + "links": [ + { + "name": "my-mcp-linux-amd64", + "url": "https://example.com/downloads/my-mcp-linux-amd64" + } + ] + } +} +``` + +Exemple : + +```go +file, _, err := manifest.LoadDefault(".") +if err != nil { + return err +} + +err = update.Run(ctx, update.Options{ + CurrentVersion: version, + BinaryName: "my-mcp", + ReleaseSource: file.Update.ReleaseSource(), + Stdout: os.Stdout, +}) +if err != nil { + return err +} +``` + +Comportement : + +- le nom de l'asset est configurable (`asset_name_template`) et supporte tout couple `GOOS/GOARCH` +- si un asset `.sha256` (ou `checksum_asset_name`) existe, le binaire téléchargé est vérifié avant remplacement +- un hook `ValidateDownloaded` permet d'ajouter une validation custom (signature, scan, etc.) +- sur Windows, le remplacement in-place n'est pas fait par défaut ; fournir `Options.ReplaceExecutable` pour une stratégie dédiée diff --git a/docs/bootstrap-cli.md b/docs/bootstrap-cli.md new file mode 100644 index 0000000..3bce155 --- /dev/null +++ b/docs/bootstrap-cli.md @@ -0,0 +1,45 @@ +# Bootstrap CLI + +Le package `bootstrap` reste optionnel : une application peut l'utiliser pour uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique. + +Exemple minimal : + +```go +func main() { + err := bootstrap.Run(context.Background(), bootstrap.Options{ + BinaryName: "my-mcp", + Description: "Client MCP", + Version: version, + EnableDoctorAlias: true, // expose `doctor` comme alias de `config test` + AliasDescriptions: map[string]string{ + "doctor": "Diagnostiquer la configuration locale.", + }, + Hooks: bootstrap.Hooks{ + Setup: func(ctx context.Context, inv bootstrap.Invocation) error { + return runSetup(ctx, inv.Args) + }, + MCP: func(ctx context.Context, inv bootstrap.Invocation) error { + return runMCP(ctx, inv.Args) + }, + ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error { + return runConfigShow(ctx, inv.Args) + }, + ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error { + return runConfigTest(ctx, inv.Args) + }, + Update: func(ctx context.Context, inv bootstrap.Invocation) error { + return runUpdate(ctx, inv.Args) + }, + }, + }) + if err != nil { + log.Fatal(err) + } +} +``` + +Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`. + +Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). +La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). +Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. diff --git a/docs/cli-helpers.md b/docs/cli-helpers.md new file mode 100644 index 0000000..4077f15 --- /dev/null +++ b/docs/cli-helpers.md @@ -0,0 +1,187 @@ +# 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 +``` + +Chaque champ peut déclarer ses propres hooks de validation (`Validate`, `ValidateBool`, `ValidateList`). +Les validations sont appliquées de manière cohérente en TTY et en stdin non interactif. + +Pour standardiser la résolution `flag > env > config > secret` avec provenance : + +```go +store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + ServiceName: "my-mcp", +}) +if err != nil { + return err +} + +lookup := cli.ResolveLookup(cli.ResolveLookupOptions{ + Flag: cli.MapLookup(flagValues), + Env: cli.EnvLookup(os.LookupEnv), + Config: cli.ConfigMap(configValues), + Secret: cli.SecretStore(store), +}) + +resolution, err := cli.ResolveFields(cli.ResolveOptions{ + Fields: []cli.FieldSpec{ + {Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"}, + {Name: "api_token", Required: true, EnvKey: "MY_MCP_API_TOKEN", SecretKey: "my-mcp-api-token"}, + {Name: "timeout", DefaultValue: "30s"}, + }, + Lookup: lookup, +}) +if err != nil { + return err +} + +if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil { + return err +} +``` + +`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et `FieldSpec.Sources` permet de définir un ordre spécifique pour un champ. + +Le package fournit aussi un socle réutilisable pour une commande `doctor`. +L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), mais peut réutiliser les checks communs et ajouter ses propres hooks : + +```go +report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ + ConfigCheck: cli.NewConfigCheck(store), + SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) { + return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + ServiceName: "my-mcp", + }) + }), + RequiredSecrets: []cli.DoctorSecret{ + {Name: "api-token", Label: "API token"}, + }, + SecretStoreFactory: func() (secretstore.Store, error) { + return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + ServiceName: "my-mcp", + }) + }, + ManifestDir: ".", + ConnectivityCheck: func(context.Context) cli.DoctorResult { + if err := pingBackend(); err != nil { + return cli.DoctorResult{ + Name: "connectivity", + Status: cli.DoctorStatusFail, + Summary: "backend is unreachable", + Detail: err.Error(), + } + } + return cli.DoctorResult{ + Name: "connectivity", + Status: cli.DoctorStatusOK, + Summary: "backend is reachable", + } + }, +}) + +if err := cli.RenderDoctorReport(os.Stdout, report); err != nil { + return err +} + +if report.HasFailures() { + os.Exit(1) +} +``` + +Pour éviter des checks custom répétitifs sur les profils, `doctor` expose aussi un helper basé sur `FieldSpec` : + +```go +report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ + ExtraChecks: []cli.DoctorCheck{ + cli.RequiredResolvedFieldsCheck(cli.ResolveOptions{ + Fields: []cli.FieldSpec{ + {Name: "base_url", Required: true, EnvKey: "MY_MCP_BASE_URL"}, + { + Name: "api_token", + Required: true, + EnvKey: "MY_MCP_API_TOKEN", + SecretKey: "my-mcp-api-token", + Sources: []cli.ValueSource{cli.SourceEnv, cli.SourceSecret}, + }, + }, + Lookup: cli.ResolveLookup(cli.ResolveLookupOptions{ + Env: cli.EnvLookup(os.LookupEnv), + Config: cli.ConfigMap(configValues), + Secret: cli.SecretStore(secretStore), + }), + }), + }, +}) +``` + +En cas d'échec de résolution, tu peux aussi réutiliser le formatteur `cli.FormatResolveFieldsError(err)` dans un check custom pour garder des messages homogènes. diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..863f14c --- /dev/null +++ b/docs/config.md @@ -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 + }, +}) +``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..9aef506 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,40 @@ +# Installation et utilisation type + +## Installation + +```bash +go get gitea.lclr.dev/AI/mcp-framework +``` + +## CLI de scaffold + +Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go : + +```bash +go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest +mcp-framework scaffold init \ + --target ./my-mcp \ + --module example.com/my-mcp \ + --binary my-mcp \ + --profiles dev,prod +``` + +Puis dans le projet généré : + +```bash +cd my-mcp +go mod tidy +go run ./cmd/my-mcp help +``` + +## Utilisation type + +Le flux typique côté application est : + +1. Déclarer les sous-commandes communes via `bootstrap` (optionnel). +2. Résoudre le profil actif avec `cli`. +3. Charger la config versionnée avec `config`. +4. Lire les secrets avec `secretstore`. +5. Charger `mcp.toml` avec `manifest`. +6. Exécuter l'auto-update avec `update` si nécessaire. +7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. diff --git a/docs/limitations.md b/docs/limitations.md new file mode 100644 index 0000000..773309a --- /dev/null +++ b/docs/limitations.md @@ -0,0 +1,3 @@ +# Limites actuelles + +- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes diff --git a/docs/manifest.md b/docs/manifest.md new file mode 100644 index 0000000..6efd016 --- /dev/null +++ b/docs/manifest.md @@ -0,0 +1,83 @@ +# 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 +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" + +[profiles] +default = "prod" +known = ["dev", "staging", "prod"] + +[bootstrap] +description = "Client MCP interne" +``` + +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`). +- `[profiles].default` : profil recommandé par défaut. +- `[profiles].known` : profils connus du projet. +- `[bootstrap].description` : description CLI utilisée par le bootstrap. + +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 +``` diff --git a/docs/minimal-example.md b/docs/minimal-example.md new file mode 100644 index 0000000..ce0b185 --- /dev/null +++ b/docs/minimal-example.md @@ -0,0 +1,36 @@ +# 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 +} +``` diff --git a/docs/packages.md b/docs/packages.md new file mode 100644 index 0000000..12aae8a --- /dev/null +++ b/docs/packages.md @@ -0,0 +1,9 @@ +# Packages + +- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `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, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. +- `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 helper runtime `OpenFromManifest`. +- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. diff --git a/docs/scaffolding.md b/docs/scaffolding.md new file mode 100644 index 0000000..ce3763f --- /dev/null +++ b/docs/scaffolding.md @@ -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//main.go`, `internal/app/app.go`, `mcp.toml`) +- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP) +- wiring initial `bootstrap + config + secretstore + update` +- `README.md` de démarrage + +Exemple : + +```go +result, err := scaffold.Generate(scaffold.Options{ + TargetDir: "./my-mcp", + ModulePath: "gitea.lclr.dev/AI/my-mcp", + BinaryName: "my-mcp", + Description: "Client MCP interne", + DefaultProfile: "prod", + Profiles: []string{"dev", "prod"}, +}) +if err != nil { + return err +} + +fmt.Printf("Scaffold generated in %s (%d files)\n", result.Root, len(result.Files)) +``` diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 0000000..522d48d --- /dev/null +++ b/docs/secrets.md @@ -0,0 +1,89 @@ +# 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 + +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: "email-mcp", + BackendPolicy: secretstore.BackendKWalletOnly, +}) +``` + +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 +``` + +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`. -- 2.45.2 From 01c0c7e1bc11a0f44c4584be4204cbbf37a008fe Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 14:23:15 +0200 Subject: [PATCH 33/58] fix(update): reject HTML artifacts during self-update --- update/update.go | 52 ++++++++++++++++++++ update/update_test.go | 108 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/update/update.go b/update/update.go index 21aa789..31c60f5 100644 --- a/update/update.go +++ b/update/update.go @@ -1,6 +1,7 @@ package update import ( + "bytes" "context" "crypto/ed25519" "crypto/sha256" @@ -23,6 +24,7 @@ import ( const defaultAssetNameTemplate = "{binary}-{os}-{arch}{ext}" const defaultMaxDownloadBytes int64 = 200 * 1024 * 1024 +const downloadedArtifactSniffBytes = 4096 type Options struct { Client *http.Client @@ -179,6 +181,10 @@ func Run(ctx context.Context, opts Options) error { } defer os.Remove(downloadPath) + if err := validateDownloadedArtifact(downloadPath, assetName); err != nil { + return err + } + if err := VerifyReleaseAssetChecksum(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil { return err } @@ -211,6 +217,52 @@ 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, "ForbiddenAccess denied" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + err := validateDownloadedArtifact(path, "graylog-mcp-linux-amd64") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "looks like an HTML page") { + t.Fatalf("error = %v", err) + } +} + +func TestValidateDownloadedArtifactAcceptsShebangScript(t *testing.T) { + path := filepath.Join(t.TempDir(), "downloaded") + content := "#!/usr/bin/env sh\necho ok\n" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + err := validateDownloadedArtifact(path, "graylog-mcp-linux-amd64") + if err != nil { + t.Fatalf("validateDownloadedArtifact: %v", err) + } +} + func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("self-replace is not supported on windows") @@ -516,6 +545,85 @@ func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { } } +func TestRunStopsWhenArtifactLooksLikeHTML(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + } + + payload, marshalErr := json.Marshal(release) + if marshalErr != nil { + t.Fatalf("Marshal release: %v", marshalErr) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + "Access denied", + )), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + replaceCalled := false + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReplaceExecutable: func(downloadPath, targetPath string) error { + replaceCalled = true + return os.WriteFile(targetPath, []byte("unexpected"), 0o755) + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "looks like an HTML page") { + t.Fatalf("error = %v", err) + } + if replaceCalled { + t.Fatal("replace hook should not have been called") + } + + got, readErr := os.ReadFile(target) + if readErr != nil { + t.Fatalf("ReadFile target: %v", readErr) + } + if string(got) != "old-binary" { + t.Fatalf("target content = %q, want unchanged binary", string(got)) + } +} + func TestRunUsesDriverWithoutExplicitLatestReleaseURL(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { -- 2.45.2 From a9378885f2a863fa1c8bf0edb1814a6c60fa9aaf Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Thu, 16 Apr 2026 16:56:00 +0200 Subject: [PATCH 34/58] feat(manifest): add embedded runtime fallback for scaffolded apps --- cli/doctor.go | 7 +++- cli/doctor_test.go | 25 ++++++++++++ docs/getting-started.md | 2 +- docs/manifest.md | 12 ++++++ docs/minimal-example.md | 4 +- docs/packages.md | 2 +- manifest/manifest.go | 33 +++++++++++++++- manifest/manifest_test.go | 61 +++++++++++++++++++++++++++++ scaffold/scaffold.go | 81 ++++++++++++++++++++++++++++++++++----- scaffold/scaffold_test.go | 7 +++- 10 files changed, 217 insertions(+), 17 deletions(-) diff --git a/cli/doctor.go b/cli/doctor.go index 8dfca94..27b15a8 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -55,6 +55,7 @@ type DoctorOptions struct { SecretStoreFactory func() (secretstore.Store, error) ManifestDir string ManifestValidator DoctorManifestValidator + ManifestCheck DoctorCheck ConnectivityCheck DoctorCheck ExtraChecks []DoctorCheck } @@ -71,7 +72,11 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport { if len(options.RequiredSecrets) > 0 && options.SecretStoreFactory != nil { checks = append(checks, RequiredSecretsCheck(options.SecretStoreFactory, options.RequiredSecrets)) } - checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator)) + if options.ManifestCheck != nil { + checks = append(checks, options.ManifestCheck) + } else { + checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator)) + } if options.ConnectivityCheck != nil { checks = append(checks, options.ConnectivityCheck) } diff --git a/cli/doctor_test.go b/cli/doctor_test.go index 04dc23f..04aeb99 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -132,6 +132,31 @@ func TestManifestCheckUsesValidator(t *testing.T) { } } +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{ diff --git a/docs/getting-started.md b/docs/getting-started.md index 9aef506..b146f73 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -35,6 +35,6 @@ Le flux typique côté application est : 2. Résoudre le profil actif avec `cli`. 3. Charger la config versionnée avec `config`. 4. Lire les secrets avec `secretstore`. -5. Charger `mcp.toml` avec `manifest`. +5. Charger le manifest runtime avec `manifest` (`mcp.toml` local, ou fallback embarqué). 6. Exécuter l'auto-update avec `update` si nécessaire. 7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. diff --git a/docs/manifest.md b/docs/manifest.md index 6efd016..0e3b064 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -1,6 +1,7 @@ # 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 : @@ -81,3 +82,14 @@ _ = bootstrapInfo _ = scaffoldInfo _ = source ``` + +Fallback fichier puis embarqué : + +```go +file, source, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest) +if err != nil { + return err +} + +fmt.Printf("manifest source: %s\n", source) // chemin du fichier ou "embedded:mcp.toml" +``` diff --git a/docs/minimal-example.md b/docs/minimal-example.md index ce0b185..2cf682d 100644 --- a/docs/minimal-example.md +++ b/docs/minimal-example.md @@ -5,6 +5,8 @@ type Profile struct { BaseURL string `json:"base_url"` } +var embeddedManifest = `...` // fallback utilisé si aucun mcp.toml runtime n'est trouvé + func run(ctx context.Context, flagProfile string) error { cfgStore := config.NewStore[Profile]("my-mcp") @@ -16,7 +18,7 @@ func run(ctx context.Context, flagProfile string) error { profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) profile := cfg.Profiles[profileName] - manifestFile, _, err := manifest.LoadDefault(".") + manifestFile, _, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest) if err != nil { return err } diff --git a/docs/packages.md b/docs/packages.md index 12aae8a..4c181c2 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -3,7 +3,7 @@ - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `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, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. +- `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. - `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 helper runtime `OpenFromManifest`. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. diff --git a/manifest/manifest.go b/manifest/manifest.go index 544e59a..b35dcf5 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -13,6 +13,7 @@ import ( ) const DefaultFile = "mcp.toml" +const EmbeddedSource = "embedded:mcp.toml" type File struct { BinaryName string `toml:"binary_name"` @@ -118,9 +119,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() diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 5d6a739..46b0ec0 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -234,3 +234,64 @@ description = " Client MCP interne " t.Fatalf("scaffold known environment variables = %v", scaffold.KnownEnvironmentVariables) } } + +func TestLoadEmbeddedParsesContent(t *testing.T) { + file, source, err := LoadEmbedded(` +[update] +latest_release_url = "https://example.com/latest" +`) + if err != nil { + t.Fatalf("LoadEmbedded returned error: %v", err) + } + if source != EmbeddedSource { + t.Fatalf("source = %q, want %q", source, EmbeddedSource) + } + if file.Update.LatestReleaseURL != "https://example.com/latest" { + t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL) + } +} + +func TestLoadEmbeddedReturnsNotExistWhenEmpty(t *testing.T) { + _, _, err := LoadEmbedded(" ") + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("error = %v, want os.ErrNotExist", err) + } +} + +func TestLoadDefaultOrEmbeddedPrefersManifestFile(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, DefaultFile) + if err := os.WriteFile(path, []byte("[update]\nlatest_release_url = \"https://example.com/from-file\"\n"), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + file, source, err := LoadDefaultOrEmbedded(root, ` +[update] +latest_release_url = "https://example.com/from-embedded" +`) + if err != nil { + t.Fatalf("LoadDefaultOrEmbedded returned error: %v", err) + } + if source != path { + t.Fatalf("source = %q, want %q", source, path) + } + if file.Update.LatestReleaseURL != "https://example.com/from-file" { + t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL) + } +} + +func TestLoadDefaultOrEmbeddedUsesEmbeddedWhenFileMissing(t *testing.T) { + file, source, err := LoadDefaultOrEmbedded(t.TempDir(), ` +[update] +latest_release_url = "https://example.com/from-embedded" +`) + if err != nil { + t.Fatalf("LoadDefaultOrEmbedded returned error: %v", err) + } + if source != EmbeddedSource { + t.Fatalf("source = %q, want %q", source, EmbeddedSource) + } + if file.Update.LatestReleaseURL != "https://example.com/from-embedded" { + t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL) + } +} diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 81e9fe7..1300206 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -888,6 +888,38 @@ import ( "gitea.lclr.dev/AI/mcp-framework/update" ) +var embeddedManifest = ` + "`" + `binary_name = "{{.BinaryName}}" +docs_url = "{{.DocsURL}}" + +[update] +source_name = "Release endpoint" +driver = "{{.ReleaseDriver}}" +repository = "{{.ReleaseRepository}}" +base_url = "{{.ReleaseBaseURL}}" +asset_name_template = "{binary}-{os}-{arch}{ext}" +checksum_asset_name = "{asset}.sha256" +checksum_required = true +signature_asset_name = "{asset}.sig" +signature_required = false +signature_public_key_env_names = ["{{.ReleasePublicKeyEnv}}"] +token_header = "Authorization" +token_prefix = "token" +token_env_names = ["{{.ReleaseTokenEnv}}"] + +[environment] +known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] + +[secret_store] +backend_policy = "{{.SecretStorePolicy}}" + +[profiles] +default = "{{.DefaultProfile}}" +known = [{{- range $index, $value := .Profiles}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] + +[bootstrap] +description = "{{.Description}}" +` + "`" + ` + type Profile struct { BaseURL string } @@ -895,6 +927,7 @@ type Profile struct { type Runtime struct { ConfigStore config.Store[Profile] Manifest manifest.File + ManifestSource string BinaryName string Description string Version string @@ -921,12 +954,13 @@ func NewRuntime(version string) (Runtime, error) { } } - manifestFile, _, err := manifest.LoadDefault(manifestStartDir) + manifestFile, manifestSource, err := manifest.LoadDefaultOrEmbedded(manifestStartDir, embeddedManifest) if err != nil { if !errors.Is(err, os.ErrNotExist) { return Runtime{}, err } manifestFile = manifest.File{} + manifestSource = "" } bootstrapInfo := manifestFile.BootstrapInfo() @@ -944,13 +978,14 @@ func NewRuntime(version string) (Runtime, error) { tokenEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[2], tokenEnv) } - return Runtime{ - ConfigStore: config.NewStore[Profile](binaryName), - Manifest: manifestFile, - BinaryName: binaryName, - Description: description, - Version: firstNonEmpty(strings.TrimSpace(version), "dev"), - DefaultProfile: defaultProfile, + return Runtime{ + ConfigStore: config.NewStore[Profile](binaryName), + Manifest: manifestFile, + ManifestSource: manifestSource, + BinaryName: binaryName, + Description: description, + Version: firstNonEmpty(strings.TrimSpace(version), "dev"), + DefaultProfile: defaultProfile, ProfileEnv: profileEnv, TokenEnv: tokenEnv, SecretName: binaryName + "-api-token", @@ -1113,7 +1148,7 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er {Name: r.SecretName, Label: "API token"}, }, SecretStoreFactory: r.openSecretStore, - ManifestDir: ".", + ManifestCheck: r.manifestDoctorCheck(), }) if err := cli.RenderDoctorReport(stdout, report); err != nil { @@ -1142,8 +1177,14 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error } func (r Runtime) openSecretStore() (secretstore.Store, error) { - return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + backendPolicy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy)) + if backendPolicy == "" { + backendPolicy = secretstore.BackendAuto + } + + return secretstore.Open(secretstore.Options{ ServiceName: r.BinaryName, + BackendPolicy: backendPolicy, LookupEnv: func(name string) (string, bool) { if name == r.SecretName { return os.LookupEnv(r.TokenEnv) @@ -1179,6 +1220,26 @@ func firstNonEmpty(values ...string) string { } return "" } + +func (r Runtime) manifestDoctorCheck() cli.DoctorCheck { + return func(context.Context) cli.DoctorResult { + source := strings.TrimSpace(r.ManifestSource) + if source == "" { + return cli.DoctorResult{ + Name: "manifest", + Status: cli.DoctorStatusWarn, + Summary: "manifest is missing, using built-in defaults", + } + } + + return cli.DoctorResult{ + Name: "manifest", + Status: cli.DoctorStatusOK, + Summary: "manifest is valid", + Detail: source, + } + } +} ` const manifestTemplate = `binary_name = "{{.BinaryName}}" diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index be726db..2f59ace 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -66,12 +66,15 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { } for _, snippet := range []string{ "config.NewStore[Profile]", - "secretstore.OpenFromManifest", + "secretstore.Open(secretstore.Options", "update.Run", - "manifest.LoadDefault", + "manifest.LoadDefaultOrEmbedded", "bootstrap.Run", "os.Executable()", "errors.Is(err, os.ErrNotExist)", + `var embeddedManifest = `, + "ManifestSource", + "ManifestCheck: r.manifestDoctorCheck()", } { if !strings.Contains(string(appGo), snippet) { t.Fatalf("app.go missing snippet %q", snippet) -- 2.45.2 From 973770ed7848518ccd93e5702f277e1eb72cf7ba Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Thu, 16 Apr 2026 17:52:25 +0200 Subject: [PATCH 35/58] feat(scaffold): install binaries from latest release in install script --- scaffold/scaffold.go | 706 ++++++++++++++++++++++++++++++++++++-- scaffold/scaffold_test.go | 7 +- 2 files changed, 688 insertions(+), 25 deletions(-) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 1300206..135f4af 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -357,6 +357,30 @@ BINARY_NAME="{{.BinaryName}}" MODULE_PATH="{{.ModulePath}}" DEFAULT_PROFILE="{{.DefaultProfile}}" PROFILE_ENV="{{.ProfileEnv}}" +DEFAULT_RELEASE_DRIVER="{{.ReleaseDriver}}" +DEFAULT_RELEASE_BASE_URL="{{.ReleaseBaseURL}}" +DEFAULT_RELEASE_REPOSITORY="{{.ReleaseRepository}}" +DEFAULT_ASSET_NAME_TEMPLATE="{binary}-{os}-{arch}{ext}" +DEFAULT_CHECKSUM_ASSET_NAME="{asset}.sha256" +DEFAULT_CHECKSUM_REQUIRED="true" +DEFAULT_TOKEN_HEADER="Authorization" +DEFAULT_TOKEN_PREFIX="token" +DEFAULT_TOKEN_ENV_NAME="{{.ReleaseTokenEnv}}" +RELEASE_DRIVER="$DEFAULT_RELEASE_DRIVER" +RELEASE_BASE_URL="$DEFAULT_RELEASE_BASE_URL" +RELEASE_REPOSITORY="$DEFAULT_RELEASE_REPOSITORY" +LATEST_RELEASE_URL="" +ASSET_NAME_TEMPLATE="$DEFAULT_ASSET_NAME_TEMPLATE" +CHECKSUM_ASSET_NAME="$DEFAULT_CHECKSUM_ASSET_NAME" +CHECKSUM_REQUIRED="$DEFAULT_CHECKSUM_REQUIRED" +TOKEN_HEADER="$DEFAULT_TOKEN_HEADER" +TOKEN_PREFIX="$DEFAULT_TOKEN_PREFIX" +AUTH_HEADER_NAME="" +AUTH_HEADER_VALUE="" +TOKEN_ENV_NAMES=() +if [ -n "$DEFAULT_TOKEN_ENV_NAME" ]; then + TOKEN_ENV_NAMES=("$DEFAULT_TOKEN_ENV_NAME") +fi PREFILL_SERVER_NAME="" PREFILL_PROFILE_VALUE="" PREFILL_COMMAND_PATH="" @@ -543,15 +567,546 @@ toml_escape() { printf "%s" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' } -go_bin_dir() { - local gobin - gobin="$(go env GOBIN 2>/dev/null || true)" - if [ -n "$gobin" ]; then - printf "%s\n" "$gobin" +trim_whitespace() { + printf "%s" "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' +} + +unquote_toml() { + local value + value="$(trim_whitespace "$1")" + if [ "${#value}" -ge 2 ]; then + case "$value" in + \"*\") + value="${value#\"}" + value="${value%\"}" + ;; + \'*\') + value="${value#\'}" + value="${value%\'}" + ;; + esac + fi + printf "%s" "$value" +} + +normalize_bool() { + local value + value="$(printf "%s" "$1" | tr '[:upper:]' '[:lower:]')" + value="$(trim_whitespace "$value")" + case "$value" in + true|1|yes|y) + printf "true" + ;; + false|0|no|n) + printf "false" + ;; + *) + printf "%s" "$2" + ;; + esac +} + +toml_read_update_value() { + local key="$1" + awk -v key="$key" ' + BEGIN { + in_update = 0 + } + { + line = $0 + sub(/[[:space:]]*#.*/, "", line) + if (line ~ /^[[:space:]]*\[/) { + if (line ~ /^[[:space:]]*\[update\][[:space:]]*$/) { + in_update = 1 + } else { + in_update = 0 + } + } + if (in_update == 1 && line ~ "^[[:space:]]*" key "[[:space:]]*=") { + sub("^[[:space:]]*" key "[[:space:]]*=[[:space:]]*", "", line) + print line + exit + } + } + ' mcp.toml +} + +toml_read_update_array() { + local key="$1" + local raw + raw="$(toml_read_update_value "$key")" + raw="$(trim_whitespace "$raw")" + if [ -z "$raw" ]; then return fi - go env GOPATH 2>/dev/null | awk '{print $1 "/bin"}' + raw="${raw#\[}" + raw="${raw%\]}" + local items=() + local item="" + IFS=',' read -r -a items <<< "$raw" + for item in "${items[@]}"; do + item="$(unquote_toml "$item")" + item="$(trim_whitespace "$item")" + if [ -n "$item" ]; then + printf "%s\n" "$item" + fi + done +} + +normalize_release_config() { + RELEASE_DRIVER="$(printf "%s" "$RELEASE_DRIVER" | tr '[:upper:]' '[:lower:]')" + RELEASE_DRIVER="$(trim_whitespace "$RELEASE_DRIVER")" + + RELEASE_REPOSITORY="$(trim_whitespace "$RELEASE_REPOSITORY")" + RELEASE_REPOSITORY="${RELEASE_REPOSITORY#/}" + RELEASE_REPOSITORY="${RELEASE_REPOSITORY%/}" + + RELEASE_BASE_URL="$(trim_whitespace "$RELEASE_BASE_URL")" + RELEASE_BASE_URL="${RELEASE_BASE_URL%/}" + + LATEST_RELEASE_URL="$(trim_whitespace "$LATEST_RELEASE_URL")" + ASSET_NAME_TEMPLATE="$(trim_whitespace "$ASSET_NAME_TEMPLATE")" + CHECKSUM_ASSET_NAME="$(trim_whitespace "$CHECKSUM_ASSET_NAME")" + TOKEN_HEADER="$(trim_whitespace "$TOKEN_HEADER")" + TOKEN_PREFIX="$(trim_whitespace "$TOKEN_PREFIX")" + + if [ -z "$ASSET_NAME_TEMPLATE" ]; then + ASSET_NAME_TEMPLATE="$DEFAULT_ASSET_NAME_TEMPLATE" + fi + if [ -z "$CHECKSUM_ASSET_NAME" ]; then + CHECKSUM_ASSET_NAME="$DEFAULT_CHECKSUM_ASSET_NAME" + fi + + local filtered_env_names=() + local env_name="" + for env_name in "${TOKEN_ENV_NAMES[@]}"; do + env_name="$(trim_whitespace "$env_name")" + if [ -n "$env_name" ]; then + filtered_env_names+=("$env_name") + fi + done + TOKEN_ENV_NAMES=("${filtered_env_names[@]}") + + case "$RELEASE_DRIVER" in + gitea) + if [ -z "$TOKEN_HEADER" ]; then + TOKEN_HEADER="Authorization" + fi + if [ -z "$TOKEN_PREFIX" ]; then + TOKEN_PREFIX="token" + fi + if [ "${#TOKEN_ENV_NAMES[@]}" -eq 0 ]; then + TOKEN_ENV_NAMES=("GITEA_TOKEN") + fi + ;; + gitlab) + if [ -z "$RELEASE_BASE_URL" ]; then + RELEASE_BASE_URL="https://gitlab.com" + fi + if [ -z "$TOKEN_HEADER" ]; then + TOKEN_HEADER="PRIVATE-TOKEN" + fi + if [ "${#TOKEN_ENV_NAMES[@]}" -eq 0 ]; then + TOKEN_ENV_NAMES=("GITLAB_TOKEN" "GITLAB_PRIVATE_TOKEN") + fi + ;; + github) + if [ -z "$RELEASE_BASE_URL" ]; then + RELEASE_BASE_URL="https://api.github.com" + fi + if [ -z "$TOKEN_HEADER" ]; then + TOKEN_HEADER="Authorization" + fi + if [ -z "$TOKEN_PREFIX" ]; then + TOKEN_PREFIX="Bearer" + fi + if [ "${#TOKEN_ENV_NAMES[@]}" -eq 0 ]; then + TOKEN_ENV_NAMES=("GITHUB_TOKEN") + fi + ;; + esac +} + +load_release_config_from_manifest() { + if [ ! -f "mcp.toml" ]; then + ui_warn "mcp.toml introuvable: utilisation de la configuration release integree au script." + normalize_release_config + return + fi + + local value + value="$(toml_read_update_value "driver")" + if [ -n "$value" ]; then + RELEASE_DRIVER="$(printf "%s" "$(unquote_toml "$value")" | tr '[:upper:]' '[:lower:]')" + fi + + value="$(toml_read_update_value "repository")" + if [ -n "$value" ]; then + RELEASE_REPOSITORY="$(unquote_toml "$value")" + fi + + value="$(toml_read_update_value "base_url")" + if [ -n "$value" ]; then + RELEASE_BASE_URL="$(unquote_toml "$value")" + fi + + value="$(toml_read_update_value "latest_release_url")" + if [ -n "$value" ]; then + LATEST_RELEASE_URL="$(unquote_toml "$value")" + fi + + value="$(toml_read_update_value "asset_name_template")" + if [ -n "$value" ]; then + ASSET_NAME_TEMPLATE="$(unquote_toml "$value")" + fi + + value="$(toml_read_update_value "checksum_asset_name")" + if [ -n "$value" ]; then + CHECKSUM_ASSET_NAME="$(unquote_toml "$value")" + fi + + value="$(toml_read_update_value "checksum_required")" + if [ -n "$value" ]; then + CHECKSUM_REQUIRED="$(normalize_bool "$(unquote_toml "$value")" "$CHECKSUM_REQUIRED")" + fi + + value="$(toml_read_update_value "token_header")" + if [ -n "$value" ]; then + TOKEN_HEADER="$(unquote_toml "$value")" + fi + + value="$(toml_read_update_value "token_prefix")" + if [ -n "$value" ]; then + TOKEN_PREFIX="$(unquote_toml "$value")" + fi + + local parsed_token_env_names=() + local parsed_env_name="" + while IFS= read -r parsed_env_name; do + if [ -n "$parsed_env_name" ]; then + parsed_token_env_names+=("$parsed_env_name") + fi + done < <(toml_read_update_array "token_env_names") + if [ "${#parsed_token_env_names[@]}" -gt 0 ]; then + TOKEN_ENV_NAMES=("${parsed_token_env_names[@]}") + fi + + normalize_release_config +} + +resolve_goos() { + case "$(uname -s)" in + Linux) + printf "linux" + ;; + Darwin) + printf "darwin" + ;; + FreeBSD) + printf "freebsd" + ;; + OpenBSD) + printf "openbsd" + ;; + NetBSD) + printf "netbsd" + ;; + CYGWIN*|MINGW*|MSYS*) + printf "windows" + ;; + *) + ui_error "OS non supporte: $(uname -s)" + exit 1 + ;; + esac +} + +resolve_goarch() { + case "$(uname -m)" in + x86_64|amd64) + printf "amd64" + ;; + aarch64|arm64) + printf "arm64" + ;; + armv7*|armv6*|armhf) + printf "arm" + ;; + i386|i686) + printf "386" + ;; + ppc64le) + printf "ppc64le" + ;; + s390x) + printf "s390x" + ;; + riscv64) + printf "riscv64" + ;; + *) + ui_error "Architecture non supportee: $(uname -m)" + exit 1 + ;; + esac +} + +resolve_asset_name() { + local goos="$1" + local goarch="$2" + local ext="" + if [ "$goos" = "windows" ]; then + ext=".exe" + fi + + local template="$ASSET_NAME_TEMPLATE" + if [ -z "$template" ]; then + template="$DEFAULT_ASSET_NAME_TEMPLATE" + fi + + local asset_name="$template" + asset_name="${asset_name//\{binary\}/$BINARY_NAME}" + asset_name="${asset_name//\{os\}/$goos}" + asset_name="${asset_name//\{arch\}/$goarch}" + asset_name="${asset_name//\{ext\}/$ext}" + asset_name="$(trim_whitespace "$asset_name")" + + if [ -z "$asset_name" ]; then + ui_error "Le template d'asset resolve vers une valeur vide." + exit 1 + fi + + case "$asset_name" in + */*|*\\*) + ui_error "Nom d'asset invalide (separateur de chemin): $asset_name" + exit 1 + ;; + esac + + printf "%s" "$asset_name" +} + +resolve_latest_release_url() { + if [ -n "$LATEST_RELEASE_URL" ]; then + printf "%s" "$LATEST_RELEASE_URL" + return + fi + + if [ -z "$RELEASE_DRIVER" ]; then + ui_error "Configuration release incomplete: definir [update].driver ou [update].latest_release_url." + exit 1 + fi + + if [ -z "$RELEASE_REPOSITORY" ]; then + ui_error "Configuration release incomplete: definir [update].repository." + exit 1 + fi + + case "$RELEASE_DRIVER" in + gitea) + if [ -z "$RELEASE_BASE_URL" ]; then + ui_error "Configuration release incomplete: [update].base_url requis pour driver gitea." + exit 1 + fi + printf "%s/api/v1/repos/%s/releases/latest" "$RELEASE_BASE_URL" "$RELEASE_REPOSITORY" + ;; + gitlab) + local encoded_repository + encoded_repository="$(jq -nr --arg value "$RELEASE_REPOSITORY" '$value|@uri')" + printf "%s/api/v4/projects/%s/releases/permalink/latest" "$RELEASE_BASE_URL" "$encoded_repository" + ;; + github) + printf "%s/repos/%s/releases/latest" "$RELEASE_BASE_URL" "$RELEASE_REPOSITORY" + ;; + *) + ui_error "Driver release non supporte: $RELEASE_DRIVER (attendu: gitea, gitlab ou github)." + exit 1 + ;; + esac +} + +resolve_auth_header() { + AUTH_HEADER_NAME="" + AUTH_HEADER_VALUE="" + + local token="" + local env_name + for env_name in "${TOKEN_ENV_NAMES[@]}"; do + if [ -z "$env_name" ]; then + continue + fi + if [ -n "${!env_name:-}" ]; then + token="$(trim_whitespace "${!env_name}")" + if [ -n "$token" ]; then + break + fi + fi + done + + if [ -z "$token" ]; then + return + fi + + local header_name + header_name="$(trim_whitespace "$TOKEN_HEADER")" + if [ -z "$header_name" ]; then + return + fi + + local prefix + prefix="$(trim_whitespace "$TOKEN_PREFIX")" + if [ -n "$prefix" ]; then + local lower_token + local lower_prefix + lower_token="$(printf "%s" "$token" | tr '[:upper:]' '[:lower:]')" + lower_prefix="$(printf "%s" "$prefix" | tr '[:upper:]' '[:lower:]')" + if [[ "$lower_token" != "$lower_prefix"* ]]; then + token="$prefix $token" + fi + fi + + AUTH_HEADER_NAME="$header_name" + AUTH_HEADER_VALUE="$token" +} + +curl_download() { + local url="$1" + local output_path="$2" + local mode="${3:-}" + local curl_args=( + -fsSL + -H "User-Agent: mcp install wizard" + ) + + if [ "$mode" = "json" ]; then + curl_args+=(-H "Accept: application/json") + fi + if [ -n "$AUTH_HEADER_NAME" ] && [ -n "$AUTH_HEADER_VALUE" ]; then + curl_args+=(-H "${AUTH_HEADER_NAME}: ${AUTH_HEADER_VALUE}") + fi + + curl "${curl_args[@]}" "$url" -o "$output_path" +} + +resolve_url_reference() { + local value="$1" + local base="$2" + + if printf "%s" "$value" | grep -Eq '^https?://'; then + printf "%s" "$value" + return + fi + + local origin + origin="$(printf "%s" "$base" | sed -E 's#^(https?://[^/]+).*$#\1#')" + + if [ -z "$origin" ]; then + printf "%s" "$value" + return + fi + + if [ "${value#/}" != "$value" ]; then + printf "%s%s" "$origin" "$value" + return + fi + + local base_no_query + base_no_query="$(printf "%s" "$base" | sed 's/[?#].*$//')" + local base_dir="${base_no_query%/*}" + printf "%s/%s" "$base_dir" "$value" +} + +resolve_asset_url_from_release() { + local release_json_path="$1" + local asset_name="$2" + jq -r --arg asset "$asset_name" ' + ( + .assets.links[]? | select(.name == $asset) | (.direct_asset_url // .browser_download_url // .url // empty) + ), + ( + .assets[]? | select(.name == $asset) | (.direct_asset_url // .browser_download_url // .url // empty) + ) + | select(. != null and . != "") + ' "$release_json_path" | head -n 1 +} + +release_assets_preview() { + local release_json_path="$1" + jq -r ' + [ + (.assets.links[]?.name), + (.assets[]?.name) + ] + | map(select(. != null and . != "")) + | unique + | .[:8] + | join(", ") + ' "$release_json_path" +} + +parse_checksum_for_asset() { + local checksum_file="$1" + local asset_name="$2" + awk -v asset="$asset_name" ' + function is_hex(value) { + return value ~ /^[0-9A-Fa-f]{64}$/ + } + { + line = $0 + sub(/#.*/, "", line) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", line) + if (line == "") { + next + } + + n = split(line, fields, /[[:space:]]+/) + if (n == 1 && is_hex(fields[1]) && fallback == "") { + fallback = fields[1] + next + } + + if (n >= 2 && is_hex(fields[1])) { + candidate = fields[2] + sub(/^\*/, "", candidate) + sub(/^\.\/+/, "", candidate) + if (candidate == asset) { + print fields[1] + exit + } + } + + if (n >= 2 && is_hex(fields[n])) { + candidate = fields[1] + sub(/^\*/, "", candidate) + sub(/^\.\/+/, "", candidate) + if (candidate == asset) { + print fields[n] + exit + } + } + } + END { + if (fallback != "") { + print fallback + } + } + ' "$checksum_file" +} + +sha256_file() { + local file_path="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file_path" | awk '{print $1}' + return + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file_path" | awk '{print $1}' + return + fi + + ui_error "Aucun outil SHA-256 detecte (sha256sum ou shasum requis)." + exit 1 } resolve_binary_path() { @@ -560,13 +1115,9 @@ resolve_binary_path() { return fi - if command -v go >/dev/null 2>&1; then - local bin_dir - bin_dir="$(go_bin_dir)" - if [ -n "$bin_dir" ] && [ -x "$bin_dir/$BINARY_NAME" ]; then - printf "%s\n" "$bin_dir/$BINARY_NAME" - return - fi + if [ -x "$HOME/.local/bin/$BINARY_NAME" ]; then + printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" + return fi printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" @@ -583,10 +1134,18 @@ ensure_cli() { } install_binary() { + ensure_cli "curl" + ensure_cli "jq" + + load_release_config_from_manifest + resolve_auth_header + + local target_path="$HOME/.local/bin/$BINARY_NAME" if command -v "$BINARY_NAME" >/dev/null 2>&1; then - ui_success "Binaire detecte: $(command -v "$BINARY_NAME")" + target_path="$(command -v "$BINARY_NAME")" + ui_success "Binaire detecte: $target_path" local reinstall - reinstall="$(prompt "Reinstaller via go install ? (y/N)" "N")" + reinstall="$(prompt "Reinstaller depuis la derniere release ? (y/N)" "N")" case "$reinstall" in y|Y|yes|YES) ;; @@ -596,19 +1155,118 @@ install_binary() { esac fi - if ! command -v go >/dev/null 2>&1; then - ui_error "Go n'est pas installe. Installe Go ou choisis une configuration manuelle." + if [ ! -w "$(dirname "$target_path")" ]; then + ui_warn "Pas de droit d'ecriture dans $(dirname "$target_path"), installation dans $HOME/.local/bin." + target_path="$HOME/.local/bin/$BINARY_NAME" + fi + + local goos + goos="$(resolve_goos)" + local goarch + goarch="$(resolve_goarch)" + local asset_name + asset_name="$(resolve_asset_name "$goos" "$goarch")" + local release_url + release_url="$(resolve_latest_release_url)" + + ui_info "Recherche de la derniere release pour $RELEASE_REPOSITORY ($goos/$goarch)..." + local release_json + release_json="$(mktemp)" + if ! curl_download "$release_url" "$release_json" "json"; then + rm -f "$release_json" + ui_error "Impossible de recuperer la release latest depuis $release_url." + if [ "${#TOKEN_ENV_NAMES[@]}" -gt 0 ]; then + ui_info "Si le repo est prive, configure un token via: ${TOKEN_ENV_NAMES[*]}" + fi exit 1 fi - ui_info "Installation du binaire via go install..." - go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest" + local release_tag + release_tag="$(jq -r '.tag_name // empty' "$release_json")" + if [ -z "$release_tag" ]; then + rm -f "$release_json" + ui_error "Reponse release invalide: tag_name manquant." + exit 1 + fi - local bin_dir - bin_dir="$(go_bin_dir)" - if [ -n "$bin_dir" ]; then - ui_success "Binaire installe dans $bin_dir" - ui_info "Ajoute ce dossier au PATH si necessaire." + local asset_url + asset_url="$(resolve_asset_url_from_release "$release_json" "$asset_name")" + if [ -z "$asset_url" ]; then + local preview + preview="$(release_assets_preview "$release_json")" + rm -f "$release_json" + ui_error "Aucun asset \"$asset_name\" dans la release $release_tag." + if [ -n "$preview" ]; then + ui_info "Assets disponibles: $preview" + fi + exit 1 + fi + asset_url="$(resolve_url_reference "$asset_url" "$release_url")" + + ui_info "Telechargement de l'asset $asset_name ($release_tag)..." + local download_path + download_path="$(mktemp)" + if ! curl_download "$asset_url" "$download_path"; then + rm -f "$release_json" "$download_path" + ui_error "Echec du telechargement de l'asset: $asset_url" + exit 1 + fi + + local checksum_asset_name + checksum_asset_name="${CHECKSUM_ASSET_NAME//\{asset\}/$asset_name}" + if [ -z "$checksum_asset_name" ]; then + checksum_asset_name="${asset_name}.sha256" + fi + local checksum_url + checksum_url="$(resolve_asset_url_from_release "$release_json" "$checksum_asset_name")" + if [ -n "$checksum_url" ]; then + checksum_url="$(resolve_url_reference "$checksum_url" "$release_url")" + local checksum_path + checksum_path="$(mktemp)" + if ! curl_download "$checksum_url" "$checksum_path"; then + rm -f "$release_json" "$download_path" "$checksum_path" + ui_error "Echec du telechargement du checksum: $checksum_url" + exit 1 + fi + + local expected_checksum + expected_checksum="$(parse_checksum_for_asset "$checksum_path" "$asset_name")" + if [ -z "$expected_checksum" ]; then + rm -f "$release_json" "$download_path" "$checksum_path" + ui_error "Checksum introuvable dans $checksum_asset_name pour l'asset $asset_name." + exit 1 + fi + + local actual_checksum + actual_checksum="$(sha256_file "$download_path")" + local actual_checksum_lc + actual_checksum_lc="$(printf "%s" "$actual_checksum" | tr '[:upper:]' '[:lower:]')" + local expected_checksum_lc + expected_checksum_lc="$(printf "%s" "$expected_checksum" | tr '[:upper:]' '[:lower:]')" + if [ "$actual_checksum_lc" != "$expected_checksum_lc" ]; then + rm -f "$release_json" "$download_path" "$checksum_path" + ui_error "Checksum invalide pour $asset_name." + exit 1 + fi + rm -f "$checksum_path" + ui_success "Checksum verifie pour $asset_name." + else + if [ "$CHECKSUM_REQUIRED" = "true" ]; then + rm -f "$release_json" "$download_path" + ui_error "Checksum requis mais asset \"$checksum_asset_name\" introuvable." + exit 1 + fi + ui_warn "Checksum non disponible pour $asset_name (verification ignoree)." + fi + + chmod +x "$download_path" + mkdir -p "$(dirname "$target_path")" + mv "$download_path" "$target_path" + rm -f "$release_json" + ui_success "Binaire installe: $target_path" + + if ! printf ":%s:" "$PATH" | grep -Fq ":$(dirname "$target_path"):"; then + ui_info "Ajoute $(dirname "$target_path") au PATH si necessaire." fi } diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 2f59ace..67e3aef 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -127,7 +127,12 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "#!/usr/bin/env bash", `MODULE_PATH="example.com/acme/my-mcp"`, - `go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`, + `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.`, -- 2.45.2 From bba7aacedfce4d3774334db847d8b011aa9e7d72 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 08:30:35 +0200 Subject: [PATCH 36/58] feat(secretstore): add bitwarden CLI backend support --- docs/manifest.md | 2 +- docs/secrets.md | 12 ++ secretstore/bitwarden.go | 345 ++++++++++++++++++++++++++++++++++ secretstore/bitwarden_test.go | 341 +++++++++++++++++++++++++++++++++ secretstore/store.go | 26 ++- 5 files changed, 715 insertions(+), 11 deletions(-) create mode 100644 secretstore/bitwarden.go create mode 100644 secretstore/bitwarden_test.go diff --git a/docs/manifest.md b/docs/manifest.md index 0e3b064..5868c40 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -59,7 +59,7 @@ Champs supportés : - `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`). +- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`, `bitwarden-cli`). - `[profiles].default` : profil recommandé par défaut. - `[profiles].known` : profils connus du projet. - `[bootstrap].description` : description CLI utilisée par le bootstrap. diff --git a/docs/secrets.md b/docs/secrets.md index 522d48d..9fcbf66 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -6,6 +6,7 @@ Le package `secretstore` supporte plusieurs politiques de backend : - `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 : @@ -60,6 +61,17 @@ store, err := secretstore.Open(secretstore.Options{ }) ``` +Pour imposer Bitwarden via son CLI : + +```go +store, err := secretstore.Open(secretstore.Options{ + ServiceName: "email-mcp", + BackendPolicy: secretstore.BackendBitwardenCLI, + // Optionnel si `bw` n'est pas dans le PATH : + // BitwardenCommand: "/usr/local/bin/bw", +}) +``` + Pour stocker un secret structuré en JSON : ```go diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go new file mode 100644 index 0000000..55497c7 --- /dev/null +++ b/secretstore/bitwarden.go @@ -0,0 +1,345 @@ +package secretstore + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" +) + +const ( + defaultBitwardenCommand = "bw" + bitwardenSecretFieldName = "mcp-secret" +) + +type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error) + +var runBitwardenCLI bitwardenRunner = executeBitwardenCLI + +type bitwardenStore struct { + command string + serviceName string +} + +type bitwardenListItem struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func newBitwardenStore(options Options, policy BackendPolicy, serviceName string) (Store, error) { + command := strings.TrimSpace(options.BitwardenCommand) + if command == "" { + command = defaultBitwardenCommand + } + + store := &bitwardenStore{ + command: command, + serviceName: serviceName, + } + + if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return nil, fmt.Errorf( + "secret backend policy %q requires bitwarden CLI command %q in PATH: %w", + policy, + command, + ErrBackendUnavailable, + ) + } + + return nil, fmt.Errorf( + "secret backend policy %q cannot verify bitwarden CLI command %q: %w", + policy, + command, + errors.Join(ErrBackendUnavailable, err), + ) + } + + return store, nil +} + +func (s *bitwardenStore) SetSecret(name, label, secret string) error { + secretName := s.scopedName(name) + item, err := s.findItem(secretName) + switch { + case errors.Is(err, ErrNotFound): + template, err := s.itemTemplate() + if err != nil { + return err + } + + setBitwardenSecretPayload(template, 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 + } + return nil + case err != nil: + return err + } + + payload, err := s.itemByID(item.ID) + if err != nil { + return err + } + + setBitwardenSecretPayload(payload, 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 + } + + return nil +} + +func (s *bitwardenStore) GetSecret(name string) (string, error) { + secretName := s.scopedName(name) + item, err := s.findItem(secretName) + if err != nil { + return "", err + } + + payload, err := s.itemByID(item.ID) + 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) + } + + return secret, nil +} + +func (s *bitwardenStore) DeleteSecret(name string) error { + secretName := s.scopedName(name) + item, err := s.findItem(secretName) + if errors.Is(err, ErrNotFound) { + 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 + } + + return nil +} + +func (s *bitwardenStore) scopedName(name string) string { + return fmt.Sprintf("%s/%s", s.serviceName, name) +} + +func (s *bitwardenStore) findItem(secretName string) (bitwardenListItem, error) { + output, err := s.execute( + fmt.Sprintf("list bitwarden items for secret %q", secretName), + nil, + "list", + "items", + "--search", + secretName, + ) + if err != nil { + return bitwardenListItem{}, err + } + + var items []bitwardenListItem + if err := json.Unmarshal(output, &items); err != nil { + return bitwardenListItem{}, 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) + } + + switch len(matches) { + case 0: + return bitwardenListItem{}, ErrNotFound + case 1: + return matches[0], nil + default: + return bitwardenListItem{}, fmt.Errorf( + "multiple bitwarden items match secret %q for 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 := runBitwardenCLI(s.command, stdin, args...) + if err != nil { + return nil, fmt.Errorf("%s: %w", operation, err) + } + + return output, nil +} + +func setBitwardenSecretPayload(payload map[string]any, 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, + }, + } +} + +func readBitwardenSecret(payload map[string]any) (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) != bitwardenSecretFieldName { + continue + } + + value, ok := field["value"].(string) + if !ok { + return "", false + } + + return value, true + } + + return "", false +} + +func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { + 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 { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + if detail == "" { + return nil, err + } + return nil, fmt.Errorf("%w: %s", err, detail) + } + + return stdout.Bytes(), nil +} diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go new file mode 100644 index 0000000..8f6e2f0 --- /dev/null +++ b/secretstore/bitwarden_test.go @@ -0,0 +1,341 @@ +package secretstore + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) { + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if _, ok := store.(*bitwardenStore); !ok { + t.Fatalf("store type = %T, want *bitwardenStore", store) + } + if !fakeCLI.versionChecked { + t.Fatal("expected bitwarden CLI version check") + } +} + +func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) { + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "graylog-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret = %q, want secret-v1", value) + } + + if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil { + t.Fatalf("SetSecret (update) returned error: %v", err) + } + + value, err = store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret (updated) returned error: %v", err) + } + if value != "secret-v2" { + t.Fatalf("GetSecret (updated) = %q, want secret-v2", value) + } + + if err := store.DeleteSecret("api-token"); err != nil { + t.Fatalf("DeleteSecret returned error: %v", err) + } + + _, err = store.GetSecret("api-token") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err) + } +} + +func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + return nil, &exec.Error{Name: command, Err: exec.ErrNotFound} + }) + + _, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBackendUnavailable) { + t.Fatalf("error = %v, want ErrBackendUnavailable", err) + } +} + +func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := OpenFromManifest(OpenFromManifestOptions{ + ServiceName: "email-mcp", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)}, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("OpenFromManifest returned error: %v", err) + } + + if err := store.SetSecret("smtp-password", "SMTP password", "super-secret"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + + value, err := store.GetSecret("smtp-password") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "super-secret" { + t.Fatalf("GetSecret = %q, want super-secret", value) + } +} + +func withBitwardenRunner( + t *testing.T, + runner func(command string, stdin []byte, args ...string) ([]byte, error), +) { + t.Helper() + + previous := runBitwardenCLI + runBitwardenCLI = runner + t.Cleanup(func() { + runBitwardenCLI = previous + }) +} + +type fakeBitwardenCLI struct { + command string + itemsByID map[string]fakeBitwardenItem + nextID int + versionChecked bool +} + +type fakeBitwardenItem struct { + ID string + Name string + Notes string + Secret string +} + +func newFakeBitwardenCLI(command string) *fakeBitwardenCLI { + return &fakeBitwardenCLI{ + command: strings.TrimSpace(command), + itemsByID: map[string]fakeBitwardenItem{}, + nextID: 1, + } +} + +func (f *fakeBitwardenCLI) run(command string, stdin []byte, args ...string) ([]byte, error) { + if strings.TrimSpace(command) != f.command { + return nil, fmt.Errorf("unexpected command %q", command) + } + if len(args) == 0 { + return nil, errors.New("missing bitwarden CLI arguments") + } + + if len(args) == 1 && args[0] == "--version" { + f.versionChecked = true + return []byte("2026.1.0\n"), nil + } + + switch { + case len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search": + return f.handleListItems(args[3]) + case len(args) == 3 && args[0] == "get" && args[1] == "item": + return f.handleGetItem(args[2]) + case len(args) == 3 && args[0] == "get" && args[1] == "template" && args[2] == "item": + return []byte(`{"type":2,"name":"","notes":"","secureNote":{"type":0},"fields":[]}`), nil + case len(args) == 1 && args[0] == "encode": + return []byte(base64.StdEncoding.EncodeToString(stdin)), nil + case len(args) == 3 && args[0] == "create" && args[1] == "item": + return f.handleCreateItem(args[2]) + case len(args) == 4 && args[0] == "edit" && args[1] == "item": + return f.handleEditItem(args[2], args[3]) + case len(args) == 3 && args[0] == "delete" && args[1] == "item": + delete(f.itemsByID, strings.TrimSpace(args[2])) + return []byte(`{"success":true}`), nil + default: + return nil, fmt.Errorf("unsupported bitwarden CLI invocation: %v", args) + } +} + +func (f *fakeBitwardenCLI) handleListItems(search string) ([]byte, error) { + needle := strings.TrimSpace(search) + items := make([]map[string]any, 0) + for _, item := range f.itemsByID { + if !strings.Contains(item.Name, needle) { + continue + } + items = append(items, map[string]any{ + "id": item.ID, + "name": item.Name, + "notes": item.Notes, + "fields": []map[string]any{ + {"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1}, + }, + }) + } + + payload, err := json.Marshal(items) + if err != nil { + return nil, err + } + return payload, nil +} + +func (f *fakeBitwardenCLI) handleGetItem(id string) ([]byte, error) { + item, ok := f.itemsByID[strings.TrimSpace(id)] + if !ok { + return nil, errors.New("item not found") + } + + payload, err := json.Marshal(map[string]any{ + "id": item.ID, + "name": item.Name, + "notes": item.Notes, + "fields": []map[string]any{ + {"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1}, + }, + "secureNote": map[string]any{"type": 0}, + }) + if err != nil { + return nil, err + } + + return payload, nil +} + +func (f *fakeBitwardenCLI) handleCreateItem(encoded string) ([]byte, error) { + payload, err := decodeBitwardenPayload(encoded) + if err != nil { + return nil, err + } + + item := fakeBitwardenItem{ + ID: fmt.Sprintf("item-%d", f.nextID), + Name: readString(payload, "name"), + Notes: readString(payload, "notes"), + Secret: readFakeBitwardenSecret(payload), + } + f.nextID++ + f.itemsByID[item.ID] = item + + payload["id"] = item.ID + encodedPayload, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return encodedPayload, nil +} + +func (f *fakeBitwardenCLI) handleEditItem(id, encoded string) ([]byte, error) { + trimmedID := strings.TrimSpace(id) + if _, ok := f.itemsByID[trimmedID]; !ok { + return nil, errors.New("item not found") + } + + payload, err := decodeBitwardenPayload(encoded) + if err != nil { + return nil, err + } + + item := fakeBitwardenItem{ + ID: trimmedID, + Name: readString(payload, "name"), + Notes: readString(payload, "notes"), + Secret: readFakeBitwardenSecret(payload), + } + f.itemsByID[trimmedID] = item + + payload["id"] = trimmedID + encodedPayload, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return encodedPayload, nil +} + +func decodeBitwardenPayload(encoded string) (map[string]any, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return nil, fmt.Errorf("decode encoded payload: %w", err) + } + + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("decode payload JSON: %w", err) + } + + return payload, nil +} + +func readFakeBitwardenSecret(payload map[string]any) string { + rawFields, ok := payload["fields"] + if !ok { + return "" + } + + fields, ok := rawFields.([]any) + if !ok { + return "" + } + + for _, rawField := range fields { + field, ok := rawField.(map[string]any) + if !ok { + continue + } + + name := strings.TrimSpace(readString(field, "name")) + if name != bitwardenSecretFieldName { + continue + } + + return readString(field, "value") + } + + return "" +} + +func readString(payload map[string]any, key string) string { + value, _ := payload[key].(string) + return strings.TrimSpace(value) +} diff --git a/secretstore/store.go b/secretstore/store.go index 1cd61d1..d957562 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -20,18 +20,20 @@ var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy") type BackendPolicy string const ( - BackendAuto BackendPolicy = "auto" - BackendKWalletOnly BackendPolicy = "kwallet-only" - BackendKeyringAny BackendPolicy = "keyring-any" - BackendEnvOnly BackendPolicy = "env-only" + BackendAuto BackendPolicy = "auto" + BackendKWalletOnly BackendPolicy = "kwallet-only" + BackendKeyringAny BackendPolicy = "keyring-any" + BackendEnvOnly BackendPolicy = "env-only" + BackendBitwardenCLI BackendPolicy = "bitwarden-cli" ) type Options struct { - ServiceName string - BackendPolicy BackendPolicy - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string + ServiceName string + BackendPolicy BackendPolicy + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string } type Store interface { @@ -92,6 +94,10 @@ func Open(options Options) (Store, error) { return nil, errors.New("service name must not be empty") } + if policy == BackendBitwardenCLI { + return newBitwardenStore(options, policy, serviceName) + } + available := availableKeyringPolicy() allowed, err := allowedBackends(policy, available) if err != nil { @@ -263,7 +269,7 @@ func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) { } switch trimmed { - case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly: + case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly, BackendBitwardenCLI: return trimmed, nil default: return "", invalidBackendPolicyError(trimmed) -- 2.45.2 From 7072cb2038ea38741a899abf88f5d648ddcd4df0 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 09:39:05 +0200 Subject: [PATCH 37/58] feat(secretstore): harden bitwarden readiness and secret verification --- cli/doctor.go | 54 ++++++ cli/doctor_test.go | 56 ++++++ docs/cli-helpers.md | 12 ++ docs/secrets.md | 34 ++++ scaffold/scaffold.go | 87 ++++++++-- secretstore/bitwarden.go | 246 +++++++++++++++++++++++--- secretstore/bitwarden_test.go | 317 ++++++++++++++++++++++++++++++---- secretstore/store.go | 37 ++++ secretstore/store_test.go | 85 +++++++++ 9 files changed, 859 insertions(+), 69 deletions(-) diff --git a/cli/doctor.go b/cli/doctor.go index 27b15a8..ec859e7 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -60,6 +60,13 @@ type DoctorOptions struct { ExtraChecks []DoctorCheck } +type BitwardenDoctorOptions struct { + Command 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)) @@ -220,6 +227,53 @@ func SecretStoreAvailabilityCheck(factory func() (secretstore.Store, error)) Doc } } +func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck { + return func(context.Context) DoctorResult { + err := checkBitwardenReady(secretstore.Options{ + BitwardenCommand: strings.TrimSpace(options.Command), + 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 RequiredSecretsCheck(factory func() (secretstore.Store, error), required []DoctorSecret) DoctorCheck { return func(context.Context) DoctorResult { store, err := factory() diff --git a/cli/doctor_test.go b/cli/doctor_test.go index 04aeb99..698cd74 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "os" "path/filepath" "strings" @@ -197,6 +198,61 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) { } } +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 TestRequiredResolvedFieldsCheckReportsSources(t *testing.T) { check := RequiredResolvedFieldsCheck(ResolveOptions{ Fields: []FieldSpec{ diff --git a/docs/cli-helpers.md b/docs/cli-helpers.md index 4077f15..69d97b3 100644 --- a/docs/cli-helpers.md +++ b/docs/cli-helpers.md @@ -158,6 +158,18 @@ if report.HasFailures() { } ``` +Pour une policy `bitwarden-cli`, tu peux ajouter un check dédié avec remédiations : + +```go +report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ + ExtraChecks: []cli.DoctorCheck{ + cli.BitwardenReadyCheck(cli.BitwardenDoctorOptions{ + LookupEnv: os.LookupEnv, + }), + }, +}) +``` + Pour éviter des checks custom répétitifs sur les profils, `doctor` expose aussi un helper basé sur `FieldSpec` : ```go diff --git a/docs/secrets.md b/docs/secrets.md index 9fcbf66..9d9e683 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -72,6 +72,25 @@ store, err := secretstore.Open(secretstore.Options{ }) ``` +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 stocker un secret structuré en JSON : ```go @@ -97,5 +116,20 @@ if err != nil { _ = 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... +``` + En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`. Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`. diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 135f4af..a94b97c 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -1677,6 +1677,10 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { stdout = os.Stdout } + if _, err := r.openSecretStore(); err != nil { + return fmt.Errorf("secret backend is not ready: %w", err) + } + cfg, _, err := r.ConfigStore.LoadDefault() if err != nil { return err @@ -1684,7 +1688,14 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { profileName := r.resolveProfileName(cfg.CurrentProfile) profile := cfg.Profiles[profileName] - storedToken, _ := r.readToken() + storedToken, err := r.readToken() + switch { + case err == nil: + case errors.Is(err, secretstore.ErrNotFound): + storedToken = "" + default: + return err + } result, err := cli.RunSetup(cli.SetupOptions{ Stdin: stdin, @@ -1726,16 +1737,34 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { return err } - if err := store.SetSecret(r.SecretName, "API token", tokenValue.String); err != nil { + if err := secretstore.SetSecretVerified(store, r.SecretName, "API token", tokenValue.String); err != nil { if errors.Is(err, secretstore.ErrReadOnly) { - fmt.Fprintf(stdout, "Secret store en lecture seule, exporte %s pour fournir le token.\n", r.TokenEnv) + return fmt.Errorf( + "secret store is read-only, export %s and retry setup", + r.TokenEnv, + ) } else { return err } } } - _, err = fmt.Fprintf(stdout, "Configuration sauvegardée pour le profil %q.\n", profileName) + verifiedToken, err := r.readToken() + if err != nil { + if errors.Is(err, secretstore.ErrNotFound) { + return fmt.Errorf( + "secret %q is not readable after setup, export %s and retry", + r.SecretName, + r.TokenEnv, + ) + } + return err + } + if strings.TrimSpace(verifiedToken) == "" { + return fmt.Errorf("secret %q is empty after setup", r.SecretName) + } + + _, err = fmt.Fprintf(stdout, "Configuration saved for profile %q. Secret readability confirmed.\n", profileName) return err } @@ -1789,6 +1818,12 @@ func (r Runtime) runConfigShow(_ context.Context, inv bootstrap.Invocation) erro if _, err := fmt.Fprintf(stdout, "Config: %s\n", path); err != nil { return err } + if _, err := fmt.Fprintf(stdout, "Manifest source: %s\n", r.manifestSourceLabel()); err != nil { + return err + } + if _, err := fmt.Fprintf(stdout, "Secret backend policy (active): %s\n", r.activeBackendPolicy()); err != nil { + return err + } _, err = fmt.Fprintf(stdout, "%s\n", payload) return err } @@ -1807,6 +1842,9 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er }, SecretStoreFactory: r.openSecretStore, ManifestCheck: r.manifestDoctorCheck(), + ExtraChecks: []cli.DoctorCheck{ + r.bitwardenDoctorCheck(), + }, }) if err := cli.RenderDoctorReport(stdout, report); err != nil { @@ -1835,14 +1873,9 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error } func (r Runtime) openSecretStore() (secretstore.Store, error) { - backendPolicy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy)) - if backendPolicy == "" { - backendPolicy = secretstore.BackendAuto - } - return secretstore.Open(secretstore.Options{ - ServiceName: r.BinaryName, - BackendPolicy: backendPolicy, + ServiceName: r.BinaryName, + BackendPolicy: r.activeBackendPolicy(), LookupEnv: func(name string) (string, bool) { if name == r.SecretName { return os.LookupEnv(r.TokenEnv) @@ -1852,6 +1885,22 @@ func (r Runtime) openSecretStore() (secretstore.Store, error) { }) } +func (r Runtime) activeBackendPolicy() secretstore.BackendPolicy { + policy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy)) + if policy == "" { + return secretstore.BackendAuto + } + return policy +} + +func (r Runtime) manifestSourceLabel() string { + source := strings.TrimSpace(r.ManifestSource) + if source == "" { + return "embedded defaults" + } + return source +} + func (r Runtime) readToken() (string, error) { store, err := r.openSecretStore() if err != nil { @@ -1881,12 +1930,12 @@ func firstNonEmpty(values ...string) string { func (r Runtime) manifestDoctorCheck() cli.DoctorCheck { return func(context.Context) cli.DoctorResult { - source := strings.TrimSpace(r.ManifestSource) - if source == "" { + if strings.TrimSpace(r.ManifestSource) == "" { return cli.DoctorResult{ Name: "manifest", Status: cli.DoctorStatusWarn, Summary: "manifest is missing, using built-in defaults", + Detail: fmt.Sprintf("source=%s policy=%s", r.manifestSourceLabel(), r.activeBackendPolicy()), } } @@ -1894,10 +1943,20 @@ func (r Runtime) manifestDoctorCheck() cli.DoctorCheck { Name: "manifest", Status: cli.DoctorStatusOK, Summary: "manifest is valid", - Detail: source, + Detail: fmt.Sprintf("source=%s policy=%s", r.manifestSourceLabel(), r.activeBackendPolicy()), } } } + +func (r Runtime) bitwardenDoctorCheck() cli.DoctorCheck { + if r.activeBackendPolicy() != secretstore.BackendBitwardenCLI { + return nil + } + + return cli.BitwardenReadyCheck(cli.BitwardenDoctorOptions{ + LookupEnv: os.LookupEnv, + }) +} ` const manifestTemplate = `binary_name = "{{.BinaryName}}" diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 55497c7..2a29cee 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -5,13 +5,17 @@ import ( "encoding/json" "errors" "fmt" + "os" "os/exec" "strings" ) const ( - defaultBitwardenCommand = "bw" - bitwardenSecretFieldName = "mcp-secret" + defaultBitwardenCommand = "bw" + bitwardenSessionEnvName = "BW_SESSION" + bitwardenSecretFieldName = "mcp-secret" + bitwardenServiceFieldName = "mcp-service" + bitwardenSecretNameFieldName = "mcp-secret-name" ) type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error) @@ -28,6 +32,10 @@ type bitwardenListItem struct { 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 == "" { @@ -57,12 +65,79 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string ) } + if err := EnsureBitwardenReady(Options{ + BitwardenCommand: command, + LookupEnv: options.LookupEnv, + }); err != nil { + return nil, fmt.Errorf( + "secret backend policy %q cannot use bitwarden CLI command %q right now: %w", + policy, + command, + errors.Join(ErrBackendUnavailable, err), + ) + } + return store, nil } +func EnsureBitwardenReady(options Options) error { + command := strings.TrimSpace(options.BitwardenCommand) + if command == "" { + command = defaultBitwardenCommand + } + + lookupEnv := options.LookupEnv + if lookupEnv == nil { + lookupEnv = os.LookupEnv + } + + output, err := runBitwardenCLI(command, 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)) + } + + switch strings.ToLower(strings.TrimSpace(status.Status)) { + case "unauthenticated": + return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn) + case "locked": + return fmt.Errorf( + "%w: run `export %s=\"$(bw unlock --raw)\"` then retry", + ErrBWLocked, + bitwardenSessionEnvName, + ) + case "unlocked": + session, ok := lookupEnv(bitwardenSessionEnvName) + if !ok || strings.TrimSpace(session) == "" { + return fmt.Errorf( + "%w: environment variable %q is missing; run `export %s=\"$(bw unlock --raw)\"` then retry", + ErrBWLocked, + bitwardenSessionEnvName, + bitwardenSessionEnvName, + ) + } + return nil + default: + return fmt.Errorf( + "%w: unsupported bitwarden status %q", + ErrBWUnavailable, + strings.TrimSpace(status.Status), + ) + } +} + func (s *bitwardenStore) SetSecret(name, label, secret string) error { secretName := s.scopedName(name) - item, err := s.findItem(secretName) + item, err := s.findItem(secretName, name) switch { case errors.Is(err, ErrNotFound): template, err := s.itemTemplate() @@ -70,7 +145,7 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { return err } - setBitwardenSecretPayload(template, secretName, label, secret) + setBitwardenSecretPayload(template, s.serviceName, name, secretName, label, secret) encoded, err := s.encodePayload(template) if err != nil { return err @@ -95,7 +170,7 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { return err } - setBitwardenSecretPayload(payload, secretName, label, secret) + setBitwardenSecretPayload(payload, s.serviceName, name, secretName, label, secret) encoded, err := s.encodePayload(payload) if err != nil { return err @@ -117,7 +192,7 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { func (s *bitwardenStore) GetSecret(name string) (string, error) { secretName := s.scopedName(name) - item, err := s.findItem(secretName) + item, err := s.findItem(secretName, name) if err != nil { return "", err } @@ -137,7 +212,7 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) { func (s *bitwardenStore) DeleteSecret(name string) error { secretName := s.scopedName(name) - item, err := s.findItem(secretName) + item, err := s.findItem(secretName, name) if errors.Is(err, ErrNotFound) { return nil } @@ -162,7 +237,7 @@ func (s *bitwardenStore) scopedName(name string) string { return fmt.Sprintf("%s/%s", s.serviceName, name) } -func (s *bitwardenStore) findItem(secretName string) (bitwardenListItem, error) { +func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenListItem, error) { output, err := s.execute( fmt.Sprintf("list bitwarden items for secret %q", secretName), nil, @@ -174,6 +249,9 @@ func (s *bitwardenStore) findItem(secretName string) (bitwardenListItem, error) if err != nil { return bitwardenListItem{}, err } + if strings.TrimSpace(string(output)) == "" { + return bitwardenListItem{}, ErrNotFound + } var items []bitwardenListItem if err := json.Unmarshal(output, &items); err != nil { @@ -191,14 +269,44 @@ func (s *bitwardenStore) findItem(secretName string) (bitwardenListItem, error) matches = append(matches, item) } - switch len(matches) { - case 0: + if len(matches) == 0 { return bitwardenListItem{}, ErrNotFound + } + + markedMatches := make([]bitwardenListItem, 0, len(matches)) + legacyMatches := make([]bitwardenListItem, 0, len(matches)) + for _, item := range matches { + payload, err := s.itemByID(item.ID) + if err != nil { + return bitwardenListItem{}, err + } + + if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) { + markedMatches = append(markedMatches, item) + continue + } + legacyMatches = append(legacyMatches, item) + } + + switch len(markedMatches) { + case 0: + switch len(legacyMatches) { + case 0: + return bitwardenListItem{}, ErrNotFound + case 1: + return legacyMatches[0], nil + default: + return bitwardenListItem{}, fmt.Errorf( + "multiple legacy bitwarden items match secret %q for service %q", + secretName, + s.serviceName, + ) + } case 1: - return matches[0], nil + return markedMatches[0], nil default: return bitwardenListItem{}, fmt.Errorf( - "multiple bitwarden items match secret %q for service %q", + "multiple bitwarden items share marker for secret %q and service %q", secretName, s.serviceName, ) @@ -272,7 +380,7 @@ func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) return output, nil } -func setBitwardenSecretPayload(payload map[string]any, secretName, label, secret string) { +func setBitwardenSecretPayload(payload map[string]any, serviceName, rawSecretName, secretName, label, secret string) { payload["type"] = 2 payload["name"] = secretName payload["notes"] = strings.TrimSpace(label) @@ -283,10 +391,24 @@ func setBitwardenSecretPayload(payload map[string]any, secretName, label, secret "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 @@ -304,7 +426,7 @@ func readBitwardenSecret(payload map[string]any) (string, bool) { } name, _ := field["name"].(string) - if strings.TrimSpace(name) != bitwardenSecretFieldName { + if strings.TrimSpace(name) != strings.TrimSpace(fieldName) { continue } @@ -319,6 +441,20 @@ func readBitwardenSecret(payload map[string]any) (string, bool) { 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 executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { cmd := exec.Command(command, args...) if stdin != nil { @@ -331,15 +467,81 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - detail := strings.TrimSpace(stderr.String()) - if detail == "" { - detail = strings.TrimSpace(stdout.String()) - } - if detail == "" { - return nil, err - } - return nil, fmt.Errorf("%w: %s", err, detail) + return nil, normalizeBitwardenExecutionError(err, stderr.String(), stdout.String()) } return stdout.Bytes(), nil } + +func normalizeBitwardenExecutionError(err error, stderrText, stdoutText string) error { + detail := sanitizeBitwardenErrorDetail(stderrText, stdoutText) + classification := classifyBitwardenError(detail) + if classification == nil { + classification = ErrBWUnavailable + } + + wrapped := errors.Join(classification, err) + if strings.TrimSpace(detail) == "" { + return wrapped + } + + return fmt.Errorf("%w: %s", wrapped, detail) +} + +func sanitizeBitwardenErrorDetail(stderrText, stdoutText string) string { + raw := strings.TrimSpace(stderrText) + if raw == "" { + raw = strings.TrimSpace(stdoutText) + } + if raw == "" { + return "" + } + + lines := strings.Split(raw, "\n") + cleaned := make([]string, 0, len(lines)) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + lower := strings.ToLower(trimmed) + if strings.HasPrefix(trimmed, "at ") || + strings.HasPrefix(lower, "node:internal") || + strings.HasPrefix(lower, "internal/") || + strings.HasPrefix(lower, "npm ") { + continue + } + + cleaned = append(cleaned, trimmed) + } + + if len(cleaned) == 0 { + return "" + } + + if len(cleaned) == 1 { + return cleaned[0] + } + + return cleaned[0] + " | " + cleaned[1] +} + +func classifyBitwardenError(detail string) error { + lower := strings.ToLower(strings.TrimSpace(detail)) + + switch { + case strings.Contains(lower, "not logged in"), strings.Contains(lower, "unauthenticated"): + return ErrBWNotLoggedIn + case strings.Contains(lower, "vault is locked"), strings.Contains(lower, "is locked"): + return ErrBWLocked + case strings.Contains(lower, "failed to fetch"), + strings.Contains(lower, "econnrefused"), + strings.Contains(lower, "etimedout"), + strings.Contains(lower, "unable to connect"), + strings.Contains(lower, "network"): + return ErrBWUnavailable + default: + return nil + } +} diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 8f6e2f0..9994fb5 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -14,6 +14,7 @@ import ( ) func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) { + withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") withBitwardenRunner(t, fakeCLI.run) @@ -31,9 +32,116 @@ func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) { if !fakeCLI.versionChecked { t.Fatal("expected bitwarden CLI version check") } + if !fakeCLI.statusChecked { + t.Fatal("expected bitwarden CLI status check") + } +} + +func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + return nil, &exec.Error{Name: command, Err: exec.ErrNotFound} + }) + + _, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBackendUnavailable) { + t.Fatalf("error = %v, want ErrBackendUnavailable", err) + } +} + +func TestOpenBitwardenCLIFailsWhenSessionIsMissing(t *testing.T) { + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + _, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + LookupEnv: func(name string) (string, bool) { + return "", false + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBWLocked) { + t.Fatalf("error = %v, want ErrBWLocked", err) + } + if !errors.Is(err, ErrBackendUnavailable) { + t.Fatalf("error = %v, want ErrBackendUnavailable", err) + } +} + +func TestEnsureBitwardenReadyGuidesLoginAndUnlock(t *testing.T) { + t.Run("unauthenticated", func(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"unauthenticated"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + err := EnsureBitwardenReady(Options{BitwardenCommand: "bw"}) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBWNotLoggedIn) { + t.Fatalf("error = %v, want ErrBWNotLoggedIn", err) + } + if !strings.Contains(err.Error(), "bw login") { + t.Fatalf("error = %v, want guidance with bw login", err) + } + }) + + t.Run("locked", func(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"locked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + err := EnsureBitwardenReady(Options{BitwardenCommand: "bw"}) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBWLocked) { + t.Fatalf("error = %v, want ErrBWLocked", err) + } + if !strings.Contains(err.Error(), "bw unlock --raw") { + t.Fatalf("error = %v, want guidance with bw unlock", err) + } + }) +} + +func TestEnsureBitwardenReadyAcceptsUnlockedSession(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"unlocked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + err := EnsureBitwardenReady(Options{ + BitwardenCommand: "bw", + LookupEnv: func(name string) (string, bool) { + if name == "BW_SESSION" { + return "session-token", true + } + return "", false + }, + }) + if err != nil { + t.Fatalf("EnsureBitwardenReady returned error: %v", err) + } } func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) { + withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") withBitwardenRunner(t, fakeCLI.run) @@ -79,24 +187,138 @@ func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) { } } -func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) { - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - return nil, &exec.Error{Name: command, Err: exec.ErrNotFound} - }) +func TestBitwardenStoreWritesMarkerFields(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) - _, err := Open(Options{ + store, err := Open(Options{ ServiceName: "email-mcp", BackendPolicy: BackendBitwardenCLI, }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + + var found fakeBitwardenItem + for _, item := range fakeCLI.itemsByID { + if item.Name == "email-mcp/api-token" { + found = item + break + } + } + if found.ID == "" { + t.Fatal("expected bitwarden item to be created") + } + if found.MarkerService != "email-mcp" { + t.Fatalf("marker service = %q, want email-mcp", found.MarkerService) + } + if found.MarkerSecretName != "api-token" { + t.Fatalf("marker secret = %q, want api-token", found.MarkerSecretName) + } +} + +func TestBitwardenStorePrefersStrictMarkerMatchWhenNameCollides(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "wrong-secret", + MarkerService: "other-service", + MarkerSecretName: "api-token", + } + fakeCLI.itemsByID["item-2"] = fakeBitwardenItem{ + ID: "item-2", + Name: "email-mcp/api-token", + Secret: "good-secret", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "good-secret" { + t.Fatalf("GetSecret = %q, want good-secret", value) + } +} + +func TestBitwardenStoreFallsBackToSingleLegacyItemWithoutMarkers(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["legacy-1"] = fakeBitwardenItem{ + ID: "legacy-1", + Name: "email-mcp/api-token", + Secret: "legacy-secret", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "legacy-secret" { + t.Fatalf("GetSecret = %q, want legacy-secret", value) + } +} + +func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) { + store := &bitwardenStore{command: "bw", serviceName: "email-mcp"} + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" { + return []byte(""), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + _, err := store.findItem("email-mcp/api-token", "api-token") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("error = %v, want ErrNotFound", err) + } +} + +func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh is required for this test") + } + + _, err := executeBitwardenCLI("sh", nil, "-c", "echo 'You are not logged in.' 1>&2; echo ' at Foo (node:internal/x)' 1>&2; exit 1") if err == nil { t.Fatal("expected error") } - if !errors.Is(err, ErrBackendUnavailable) { - t.Fatalf("error = %v, want ErrBackendUnavailable", err) + if !errors.Is(err, ErrBWNotLoggedIn) { + t.Fatalf("error = %v, want ErrBWNotLoggedIn", err) + } + if strings.Contains(err.Error(), "node:internal") { + t.Fatalf("error = %v, stack trace should be stripped", err) } } func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { + withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") withBitwardenRunner(t, fakeCLI.run) @@ -128,6 +350,11 @@ func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { } } +func withBitwardenSession(t *testing.T) { + t.Helper() + t.Setenv("BW_SESSION", "test-session") +} + func withBitwardenRunner( t *testing.T, runner func(command string, stdin []byte, args ...string) ([]byte, error), @@ -145,14 +372,18 @@ type fakeBitwardenCLI struct { command string itemsByID map[string]fakeBitwardenItem nextID int + status string versionChecked bool + statusChecked bool } type fakeBitwardenItem struct { - ID string - Name string - Notes string - Secret string + ID string + Name string + Notes string + Secret string + MarkerService string + MarkerSecretName string } func newFakeBitwardenCLI(command string) *fakeBitwardenCLI { @@ -160,6 +391,7 @@ func newFakeBitwardenCLI(command string) *fakeBitwardenCLI { command: strings.TrimSpace(command), itemsByID: map[string]fakeBitwardenItem{}, nextID: 1, + status: "unlocked", } } @@ -175,6 +407,10 @@ func (f *fakeBitwardenCLI) run(command string, stdin []byte, args ...string) ([] f.versionChecked = true return []byte("2026.1.0\n"), nil } + if len(args) == 1 && args[0] == "status" { + f.statusChecked = true + return []byte(fmt.Sprintf(`{"status":%q}`, strings.TrimSpace(f.status))), nil + } switch { case len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search": @@ -205,12 +441,10 @@ func (f *fakeBitwardenCLI) handleListItems(search string) ([]byte, error) { continue } items = append(items, map[string]any{ - "id": item.ID, - "name": item.Name, - "notes": item.Notes, - "fields": []map[string]any{ - {"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1}, - }, + "id": item.ID, + "name": item.Name, + "notes": item.Notes, + "fields": item.fieldsPayload(), }) } @@ -228,12 +462,10 @@ func (f *fakeBitwardenCLI) handleGetItem(id string) ([]byte, error) { } payload, err := json.Marshal(map[string]any{ - "id": item.ID, - "name": item.Name, - "notes": item.Notes, - "fields": []map[string]any{ - {"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1}, - }, + "id": item.ID, + "name": item.Name, + "notes": item.Notes, + "fields": item.fieldsPayload(), "secureNote": map[string]any{"type": 0}, }) if err != nil { @@ -250,10 +482,12 @@ func (f *fakeBitwardenCLI) handleCreateItem(encoded string) ([]byte, error) { } item := fakeBitwardenItem{ - ID: fmt.Sprintf("item-%d", f.nextID), - Name: readString(payload, "name"), - Notes: readString(payload, "notes"), - Secret: readFakeBitwardenSecret(payload), + ID: fmt.Sprintf("item-%d", f.nextID), + Name: readString(payload, "name"), + Notes: readString(payload, "notes"), + Secret: readFakeBitwardenField(payload, bitwardenSecretFieldName), + MarkerService: readFakeBitwardenField(payload, bitwardenServiceFieldName), + MarkerSecretName: readFakeBitwardenField(payload, bitwardenSecretNameFieldName), } f.nextID++ f.itemsByID[item.ID] = item @@ -278,10 +512,12 @@ func (f *fakeBitwardenCLI) handleEditItem(id, encoded string) ([]byte, error) { } item := fakeBitwardenItem{ - ID: trimmedID, - Name: readString(payload, "name"), - Notes: readString(payload, "notes"), - Secret: readFakeBitwardenSecret(payload), + ID: trimmedID, + Name: readString(payload, "name"), + Notes: readString(payload, "notes"), + Secret: readFakeBitwardenField(payload, bitwardenSecretFieldName), + MarkerService: readFakeBitwardenField(payload, bitwardenServiceFieldName), + MarkerSecretName: readFakeBitwardenField(payload, bitwardenSecretNameFieldName), } f.itemsByID[trimmedID] = item @@ -307,7 +543,7 @@ func decodeBitwardenPayload(encoded string) (map[string]any, error) { return payload, nil } -func readFakeBitwardenSecret(payload map[string]any) string { +func readFakeBitwardenField(payload map[string]any, fieldName string) string { rawFields, ok := payload["fields"] if !ok { return "" @@ -325,7 +561,7 @@ func readFakeBitwardenSecret(payload map[string]any) string { } name := strings.TrimSpace(readString(field, "name")) - if name != bitwardenSecretFieldName { + if name != fieldName { continue } @@ -335,6 +571,21 @@ func readFakeBitwardenSecret(payload map[string]any) string { return "" } +func (i fakeBitwardenItem) fieldsPayload() []map[string]any { + fields := []map[string]any{ + {"name": bitwardenSecretFieldName, "value": i.Secret, "type": 1}, + } + + if strings.TrimSpace(i.MarkerService) != "" { + fields = append(fields, map[string]any{"name": bitwardenServiceFieldName, "value": i.MarkerService, "type": 0}) + } + if strings.TrimSpace(i.MarkerSecretName) != "" { + fields = append(fields, map[string]any{"name": bitwardenSecretNameFieldName, "value": i.MarkerSecretName, "type": 0}) + } + + return fields +} + func readString(payload map[string]any, key string) string { value, _ := payload[key].(string) return strings.TrimSpace(value) diff --git a/secretstore/store.go b/secretstore/store.go index d957562..2b9bb5f 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -16,6 +16,9 @@ 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 @@ -140,6 +143,40 @@ 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 { diff --git a/secretstore/store_test.go b/secretstore/store_test.go index 818ea41..1ddd6c6 100644 --- a/secretstore/store_test.go +++ b/secretstore/store_test.go @@ -248,3 +248,88 @@ func TestJSONHelpersRoundTrip(t *testing.T) { t.Fatalf("GetJSON = %#v, want %#v", output, input) } } + +func TestEffectiveBackendPolicyReportsConcreteBackend(t *testing.T) { + t.Run("env-only", func(t *testing.T) { + store, err := Open(Options{ + BackendPolicy: BackendEnvOnly, + LookupEnv: func(string) (string, bool) { return "", false }, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if got := EffectiveBackendPolicy(store); got != BackendEnvOnly { + t.Fatalf("EffectiveBackendPolicy = %q, want %q", got, BackendEnvOnly) + } + }) + + t.Run("keyring", func(t *testing.T) { + withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) { + return &stubKeyring{}, nil + }) + + store, err := Open(Options{ + ServiceName: "mcp-framework-test", + BackendPolicy: BackendAuto, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if got := EffectiveBackendPolicy(store); got != BackendKeyringAny { + t.Fatalf("EffectiveBackendPolicy = %q, want %q", got, BackendKeyringAny) + } + }) +} + +func TestSetSecretVerifiedWritesThenReadsBack(t *testing.T) { + ring := &stubKeyring{} + withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) { + return ring, nil + }) + + store, err := Open(Options{ + ServiceName: "mcp-framework-test", + BackendPolicy: BackendAuto, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if err := SetSecretVerified(store, "token", "API token", "secret-value"); err != nil { + t.Fatalf("SetSecretVerified returned error: %v", err) + } +} + +func TestSetSecretVerifiedFailsOnReadBackMismatch(t *testing.T) { + store := &mismatchSecretStore{} + + err := SetSecretVerified(store, "token", "API token", "secret-value") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrNotFound) { + t.Fatalf("error = %v, want wrapped ErrNotFound", err) + } + if store.setCalls != 1 { + t.Fatalf("setCalls = %d, want 1", store.setCalls) + } +} + +type mismatchSecretStore struct { + setCalls int +} + +func (s *mismatchSecretStore) SetSecret(name, label, secret string) error { + s.setCalls++ + return nil +} + +func (s *mismatchSecretStore) GetSecret(name string) (string, error) { + return "", ErrNotFound +} + +func (s *mismatchSecretStore) DeleteSecret(name string) error { + return nil +} -- 2.45.2 From 7d159bfdbd6a4ed7cc7a3ff27f436646e7600e2a Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 10:56:15 +0200 Subject: [PATCH 38/58] feat: add runtime secretstore diagnostics and setup helpers --- cli/doctor.go | 53 +++++++-- cli/doctor_test.go | 82 ++++++++++++++ cli/setup_secret.go | 76 +++++++++++++ cli/setup_secret_test.go | 116 ++++++++++++++++++++ docs/cli-helpers.md | 27 +++-- docs/packages.md | 2 +- docs/secrets.md | 76 +++++++++++++ scaffold/scaffold.go | 57 +++------- scaffold/scaffold_test.go | 2 + secretstore/bitwarden.go | 63 ++++++++++- secretstore/bitwarden_test.go | 48 +++++++++ secretstore/manifest_open.go | 54 +++++++--- secretstore/runtime.go | 196 ++++++++++++++++++++++++++++++++++ secretstore/runtime_test.go | 155 +++++++++++++++++++++++++++ secretstore/store.go | 1 + 15 files changed, 934 insertions(+), 74 deletions(-) create mode 100644 cli/setup_secret.go create mode 100644 cli/setup_secret_test.go create mode 100644 secretstore/runtime.go create mode 100644 secretstore/runtime_test.go diff --git a/cli/doctor.go b/cli/doctor.go index ec859e7..293806e 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -49,19 +49,23 @@ type DoctorSecret struct { type DoctorManifestValidator func(manifest.File, string) []string type DoctorOptions struct { - ConfigCheck DoctorCheck - SecretStoreCheck DoctorCheck - RequiredSecrets []DoctorSecret - SecretStoreFactory func() (secretstore.Store, error) - ManifestDir string - ManifestValidator DoctorManifestValidator - ManifestCheck DoctorCheck - ConnectivityCheck DoctorCheck - ExtraChecks []DoctorCheck + 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 + Shell string LookupEnv func(string) (string, bool) } @@ -87,6 +91,9 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport { 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)) @@ -231,6 +238,7 @@ func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck { return func(context.Context) DoctorResult { err := checkBitwardenReady(secretstore.Options{ BitwardenCommand: strings.TrimSpace(options.Command), + Shell: strings.TrimSpace(options.Shell), LookupEnv: options.LookupEnv, }) if err == nil { @@ -274,6 +282,33 @@ func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck { } } +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() diff --git a/cli/doctor_test.go b/cli/doctor_test.go index 698cd74..bbdc15e 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -253,6 +253,88 @@ func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) }) } +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{ diff --git a/cli/setup_secret.go b/cli/setup_secret.go new file mode 100644 index 0000000..dceaacd --- /dev/null +++ b/cli/setup_secret.go @@ -0,0 +1,76 @@ +package cli + +import ( + "errors" + "fmt" + "strings" + + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type SetupSecretWriteOptions struct { + Store secretstore.Store + SecretName string + SecretLabel string + TokenEnv string + Value SetupValue +} + +func WriteSetupSecretVerified(options SetupSecretWriteOptions) error { + if options.Store == nil { + return errors.New("secret store must not be nil") + } + + secretName := strings.TrimSpace(options.SecretName) + if secretName == "" { + return errors.New("secret name must not be empty") + } + + secretLabel := strings.TrimSpace(options.SecretLabel) + if secretLabel == "" { + secretLabel = secretName + } + + if options.Value.KeptStoredSecret { + return verifyStoredSetupSecret(options.Store, secretName, options.TokenEnv) + } + if !options.Value.Set { + return nil + } + + if err := secretstore.SetSecretVerified(options.Store, secretName, secretLabel, options.Value.String); err != nil { + if errors.Is(err, secretstore.ErrReadOnly) { + tokenEnv := strings.TrimSpace(options.TokenEnv) + if tokenEnv != "" { + return fmt.Errorf("secret store is read-only, export %s and retry setup: %w", tokenEnv, err) + } + } + return fmt.Errorf("save secret %q during setup: %w", secretName, err) + } + + return nil +} + +func verifyStoredSetupSecret(store secretstore.Store, secretName, tokenEnv string) error { + secret, err := store.GetSecret(secretName) + if err != nil { + if errors.Is(err, secretstore.ErrNotFound) { + tokenEnv = strings.TrimSpace(tokenEnv) + if tokenEnv != "" { + return fmt.Errorf( + "secret %q is not readable after setup, export %s and retry: %w", + secretName, + tokenEnv, + err, + ) + } + } + return fmt.Errorf("verify secret %q after setup: %w", secretName, err) + } + + if strings.TrimSpace(secret) == "" { + return fmt.Errorf("secret %q is empty after setup", secretName) + } + + return nil +} diff --git a/cli/setup_secret_test.go b/cli/setup_secret_test.go new file mode 100644 index 0000000..d426459 --- /dev/null +++ b/cli/setup_secret_test.go @@ -0,0 +1,116 @@ +package cli + +import ( + "errors" + "strings" + "testing" + + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +func TestWriteSetupSecretVerifiedPersistsAndConfirmsReadability(t *testing.T) { + store := &setupSecretStore{secrets: map[string]string{}} + + err := WriteSetupSecretVerified(SetupSecretWriteOptions{ + Store: store, + SecretName: "api-token", + SecretLabel: "API token", + Value: SetupValue{ + Type: SetupFieldSecret, + String: "secret-v1", + Set: true, + }, + }) + if err != nil { + t.Fatalf("WriteSetupSecretVerified returned error: %v", err) + } + + if got := store.secrets["api-token"]; got != "secret-v1" { + t.Fatalf("stored secret = %q, want secret-v1", got) + } +} + +func TestWriteSetupSecretVerifiedReturnsContextForReadOnlyStores(t *testing.T) { + store := &setupSecretStore{ + secrets: map[string]string{}, + setErr: secretstore.ErrReadOnly, + } + + err := WriteSetupSecretVerified(SetupSecretWriteOptions{ + Store: store, + SecretName: "api-token", + TokenEnv: "GRAYLOG_MCP_API_TOKEN", + Value: SetupValue{ + Type: SetupFieldSecret, + String: "secret-v1", + Set: true, + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, secretstore.ErrReadOnly) { + t.Fatalf("error = %v, want ErrReadOnly", err) + } + if !strings.Contains(err.Error(), "GRAYLOG_MCP_API_TOKEN") { + t.Fatalf("error = %v, want token env remediation", err) + } +} + +func TestWriteSetupSecretVerifiedValidatesKeptStoredSecret(t *testing.T) { + store := &setupSecretStore{ + secrets: map[string]string{}, + getErr: secretstore.ErrNotFound, + } + + err := WriteSetupSecretVerified(SetupSecretWriteOptions{ + Store: store, + SecretName: "api-token", + TokenEnv: "GRAYLOG_MCP_API_TOKEN", + Value: SetupValue{ + Type: SetupFieldSecret, + String: "stored-token", + Set: true, + KeptStoredSecret: true, + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, secretstore.ErrNotFound) { + t.Fatalf("error = %v, want ErrNotFound", err) + } + if !strings.Contains(err.Error(), "GRAYLOG_MCP_API_TOKEN") { + t.Fatalf("error = %v, want token env remediation", err) + } +} + +type setupSecretStore struct { + secrets map[string]string + setErr error + getErr error +} + +func (s *setupSecretStore) SetSecret(name, label, secret string) error { + if s.setErr != nil { + return s.setErr + } + s.secrets[name] = secret + return nil +} + +func (s *setupSecretStore) GetSecret(name string) (string, error) { + if s.getErr != nil { + return "", s.getErr + } + value, ok := s.secrets[name] + if !ok { + return "", secretstore.ErrNotFound + } + return value, nil +} + +func (s *setupSecretStore) DeleteSecret(name string) error { + delete(s.secrets, name) + return nil +} diff --git a/docs/cli-helpers.md b/docs/cli-helpers.md index 69d97b3..7f59080 100644 --- a/docs/cli-helpers.md +++ b/docs/cli-helpers.md @@ -73,6 +73,20 @@ _ = 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. @@ -123,6 +137,10 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ ServiceName: "my-mcp", }) }), + SecretBackendPolicy: secretstore.BackendBitwardenCLI, + BitwardenOptions: cli.BitwardenDoctorOptions{ + LookupEnv: os.LookupEnv, + }, RequiredSecrets: []cli.DoctorSecret{ {Name: "api-token", Label: "API token"}, }, @@ -158,15 +176,12 @@ if report.HasFailures() { } ``` -Pour une policy `bitwarden-cli`, tu peux ajouter un check dédié avec remédiations : +Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement. +Pour le désactiver explicitement : ```go report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ - ExtraChecks: []cli.DoctorCheck{ - cli.BitwardenReadyCheck(cli.BitwardenDoctorOptions{ - LookupEnv: os.LookupEnv, - }), - }, + DisableAutoBitwardenCheck: true, }) ``` diff --git a/docs/packages.md b/docs/packages.md index 4c181c2..1e197e2 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -5,5 +5,5 @@ - `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. - `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 helper runtime `OpenFromManifest`. +- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helpers runtime `OpenFromManifest`, `DescribeRuntime`, `PreflightFromManifest` et formatage homogène via `FormatBackendStatus`. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. diff --git a/docs/secrets.md b/docs/secrets.md index 9d9e683..24046d1 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -131,5 +131,81 @@ effective := secretstore.EffectiveBackendPolicy(store) fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any... ``` +Pour obtenir en un seul appel une description runtime (source manifeste, policy déclarée/effective, disponibilité) : + +```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=... +``` + +Pour un préflight réutilisable dans `setup`, `config show` et `config test` : + +```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 + +1. Vérifier l'état de session : + +```bash +bw status +``` + +2. Déverrouiller le vault et exporter `BW_SESSION` : + +- Bash/Zsh : + +```bash +export BW_SESSION="$(bw unlock --raw)" +``` + +- Fish : + +```fish +set -x BW_SESSION (bw unlock --raw) +``` + +- PowerShell : + +```powershell +$env:BW_SESSION = (bw unlock --raw) +``` + +3. Vérifier lecture/écriture rapide : + +```go +if err := store.SetSecret("debug-token", "Debug token", "ok"); err != nil { + return err +} +_, err := store.GetSecret("debug-token") +return err +``` + +4. Interpréter les erreurs typées : + +- `secretstore.ErrBWNotLoggedIn` : `bw login` requis. +- `secretstore.ErrBWLocked` : vault verrouillé ou `BW_SESSION` absent. +- `secretstore.ErrBWUnavailable` : CLI/réseau indisponible. +- `secretstore.ErrBackendUnavailable` : policy non satisfiable dans le contexte courant. + En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`. Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`. diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index a94b97c..1856f3c 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -1731,37 +1731,19 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { return err } - if !tokenValue.KeptStoredSecret { - store, err := r.openSecretStore() - if err != nil { - return err - } - - if err := secretstore.SetSecretVerified(store, r.SecretName, "API token", tokenValue.String); err != nil { - if errors.Is(err, secretstore.ErrReadOnly) { - return fmt.Errorf( - "secret store is read-only, export %s and retry setup", - r.TokenEnv, - ) - } else { - return err - } - } - } - - verifiedToken, err := r.readToken() + store, err := r.openSecretStore() if err != nil { - if errors.Is(err, secretstore.ErrNotFound) { - return fmt.Errorf( - "secret %q is not readable after setup, export %s and retry", - r.SecretName, - r.TokenEnv, - ) - } return err } - if strings.TrimSpace(verifiedToken) == "" { - return fmt.Errorf("secret %q is empty after setup", r.SecretName) + + if err := cli.WriteSetupSecretVerified(cli.SetupSecretWriteOptions{ + Store: store, + SecretName: r.SecretName, + SecretLabel: "API token", + TokenEnv: r.TokenEnv, + Value: tokenValue, + }); err != nil { + return err } _, err = fmt.Fprintf(stdout, "Configuration saved for profile %q. Secret readability confirmed.\n", profileName) @@ -1835,15 +1817,16 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er } report := cli.RunDoctor(ctx, cli.DoctorOptions{ - ConfigCheck: cli.NewConfigCheck(r.ConfigStore), - SecretStoreCheck: cli.SecretStoreAvailabilityCheck(r.openSecretStore), + ConfigCheck: cli.NewConfigCheck(r.ConfigStore), + SecretStoreCheck: cli.SecretStoreAvailabilityCheck(r.openSecretStore), + SecretBackendPolicy: r.activeBackendPolicy(), RequiredSecrets: []cli.DoctorSecret{ {Name: r.SecretName, Label: "API token"}, }, SecretStoreFactory: r.openSecretStore, ManifestCheck: r.manifestDoctorCheck(), - ExtraChecks: []cli.DoctorCheck{ - r.bitwardenDoctorCheck(), + BitwardenOptions: cli.BitwardenDoctorOptions{ + LookupEnv: os.LookupEnv, }, }) @@ -1947,16 +1930,6 @@ func (r Runtime) manifestDoctorCheck() cli.DoctorCheck { } } } - -func (r Runtime) bitwardenDoctorCheck() cli.DoctorCheck { - if r.activeBackendPolicy() != secretstore.BackendBitwardenCLI { - return nil - } - - return cli.BitwardenReadyCheck(cli.BitwardenDoctorOptions{ - LookupEnv: os.LookupEnv, - }) -} ` const manifestTemplate = `binary_name = "{{.BinaryName}}" diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 67e3aef..5880090 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -75,6 +75,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { `var embeddedManifest = `, "ManifestSource", "ManifestCheck: r.manifestDoctorCheck()", + "SecretBackendPolicy: r.activeBackendPolicy()", + "cli.WriteSetupSecretVerified", } { if !strings.Contains(string(appGo), snippet) { t.Fatalf("app.go missing snippet %q", snippet) diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 2a29cee..ffcc0f7 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -7,6 +7,8 @@ import ( "fmt" "os" "os/exec" + "path/filepath" + "runtime" "strings" ) @@ -68,6 +70,7 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string if err := EnsureBitwardenReady(Options{ BitwardenCommand: command, LookupEnv: options.LookupEnv, + Shell: options.Shell, }); err != nil { return nil, fmt.Errorf( "secret backend policy %q cannot use bitwarden CLI command %q right now: %w", @@ -85,6 +88,7 @@ func EnsureBitwardenReady(options Options) error { if command == "" { command = defaultBitwardenCommand } + unlockCommand := bitwardenUnlockRemediation(command, options.Shell) lookupEnv := options.LookupEnv if lookupEnv == nil { @@ -111,18 +115,18 @@ func EnsureBitwardenReady(options Options) error { return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn) case "locked": return fmt.Errorf( - "%w: run `export %s=\"$(bw unlock --raw)\"` then retry", + "%w: run `%s` then retry", ErrBWLocked, - bitwardenSessionEnvName, + unlockCommand, ) case "unlocked": session, ok := lookupEnv(bitwardenSessionEnvName) if !ok || strings.TrimSpace(session) == "" { return fmt.Errorf( - "%w: environment variable %q is missing; run `export %s=\"$(bw unlock --raw)\"` then retry", + "%w: environment variable %q is missing; run `%s` then retry", ErrBWLocked, bitwardenSessionEnvName, - bitwardenSessionEnvName, + unlockCommand, ) } return nil @@ -135,6 +139,57 @@ func EnsureBitwardenReady(options Options) error { } } +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) item, err := s.findItem(secretName, name) diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 9994fb5..e12a662 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -140,6 +140,54 @@ func TestEnsureBitwardenReadyAcceptsUnlockedSession(t *testing.T) { } } +func TestEnsureBitwardenReadyAdaptsUnlockRemediationToShell(t *testing.T) { + t.Run("fish", func(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"locked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + err := EnsureBitwardenReady(Options{BitwardenCommand: "bw", Shell: "fish"}) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBWLocked) { + t.Fatalf("error = %v, want ErrBWLocked", err) + } + if !strings.Contains(err.Error(), "set -x BW_SESSION (bw unlock --raw)") { + t.Fatalf("error = %v, want fish unlock remediation", err) + } + }) + + t.Run("powershell", func(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"unlocked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + err := EnsureBitwardenReady(Options{ + BitwardenCommand: "bw", + Shell: "powershell", + LookupEnv: func(name string) (string, bool) { + return "", false + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBWLocked) { + t.Fatalf("error = %v, want ErrBWLocked", err) + } + if !strings.Contains(err.Error(), "$env:BW_SESSION = (bw unlock --raw)") { + t.Fatalf("error = %v, want powershell unlock remediation", err) + } + }) +} + func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) { withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") diff --git a/secretstore/manifest_open.go b/secretstore/manifest_open.go index 71e0390..9c95653 100644 --- a/secretstore/manifest_open.go +++ b/secretstore/manifest_open.go @@ -19,26 +19,43 @@ type OpenFromManifestOptions struct { LookupEnv func(string) (string, bool) KWalletAppID string KWalletFolder string + BitwardenCommand string + Shell string ManifestLoader ManifestLoader ExecutableResolver ExecutableResolver } func OpenFromManifest(options OpenFromManifestOptions) (Store, error) { - policy, err := resolveManifestBackendPolicy(options) + manifestPolicy, err := resolveManifestPolicy(options) if err != nil { return nil, err } return Open(Options{ - ServiceName: options.ServiceName, - BackendPolicy: policy, - LookupEnv: options.LookupEnv, - KWalletAppID: options.KWalletAppID, - KWalletFolder: options.KWalletFolder, + ServiceName: options.ServiceName, + BackendPolicy: manifestPolicy.Policy, + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + BitwardenCommand: strings.TrimSpace(options.BitwardenCommand), + 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 +} + +func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) { manifestLoader := options.ManifestLoader if manifestLoader == nil { manifestLoader = manifest.LoadDefault @@ -51,7 +68,7 @@ func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolic executablePath, err := executableResolver() if err != nil { - return "", fmt.Errorf("resolve executable path for manifest lookup: %w", err) + return manifestPolicyResolution{}, fmt.Errorf("resolve executable path for manifest lookup: %w", err) } startDir := filepath.Dir(strings.TrimSpace(executablePath)) @@ -62,19 +79,32 @@ func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolic file, manifestPath, err := manifestLoader(startDir) if err != nil { if errors.Is(err, os.ErrNotExist) { - return BackendAuto, nil + return manifestPolicyResolution{ + Policy: BackendAuto, + Source: "", + }, nil } - return "", fmt.Errorf("load runtime manifest from %q: %w", startDir, err) + return manifestPolicyResolution{}, fmt.Errorf("load runtime manifest from %q: %w", startDir, err) } if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" { - return BackendAuto, nil + return manifestPolicyResolution{ + Policy: BackendAuto, + Source: strings.TrimSpace(manifestPath), + }, nil } policy, err := normalizeBackendPolicy(BackendPolicy(file.SecretStore.BackendPolicy)) if err != nil { - return "", fmt.Errorf("invalid secret_store.backend_policy in manifest %q: %w", strings.TrimSpace(manifestPath), err) + return manifestPolicyResolution{}, fmt.Errorf( + "invalid secret_store.backend_policy in manifest %q: %w", + strings.TrimSpace(manifestPath), + err, + ) } - return policy, nil + return manifestPolicyResolution{ + Policy: policy, + Source: strings.TrimSpace(manifestPath), + }, nil } diff --git a/secretstore/runtime.go b/secretstore/runtime.go new file mode 100644 index 0000000..ec724dc --- /dev/null +++ b/secretstore/runtime.go @@ -0,0 +1,196 @@ +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 + 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, + 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, + 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) + } + + return desc, nil +} + +func PreflightFromManifest(options PreflightOptions) (PreflightReport, error) { + desc, err := DescribeRuntime(options) + if err != nil { + return PreflightReport{}, err + } + + if desc.Ready { + return PreflightReport{ + Status: PreflightStatusReady, + Summary: "secret backend is ready", + Runtime: desc, + }, nil + } + + summary, remediation := summarizePreflightFailure(desc.ReadyError) + return PreflightReport{ + Status: PreflightStatusFail, + Summary: summary, + Remediation: remediation, + Runtime: desc, + }, nil +} + +func BackendDisplayName(policy BackendPolicy) string { + switch policy { + case BackendBitwardenCLI: + return "Bitwarden CLI" + case BackendEnvOnly: + return "Environment variables" + case BackendKWalletOnly: + return "KWallet" + case BackendAuto: + return "automatic backend selection" + case BackendKeyringAny: + return BackendName() + default: + trimmed := strings.TrimSpace(string(policy)) + if trimmed == "" { + return "unknown backend" + } + return trimmed + } +} + +func FormatBackendStatus(desc RuntimeDescription) string { + source := manifestSourceLabel(desc.ManifestSource) + display := strings.TrimSpace(desc.DisplayName) + if display == "" { + display = BackendDisplayName(desc.EffectivePolicy) + } + + effective := desc.EffectivePolicy + if strings.TrimSpace(string(effective)) == "" { + effective = desc.DeclaredPolicy + } + + parts := []string{ + fmt.Sprintf("declared=%s", normalizeStatusPolicy(desc.DeclaredPolicy)), + fmt.Sprintf("effective=%s", normalizeStatusPolicy(effective)), + fmt.Sprintf("display=%s", display), + fmt.Sprintf("ready=%t", desc.Ready), + fmt.Sprintf("source=%s", source), + } + if desc.ReadyError != nil { + parts = append(parts, fmt.Sprintf("error=%s", strings.TrimSpace(desc.ReadyError.Error()))) + } + + return strings.Join(parts, " ") +} + +func summarizePreflightFailure(err error) (string, string) { + if err == nil { + return "secret backend is unavailable", "" + } + + switch { + case errors.Is(err, ErrBWNotLoggedIn): + return "bitwarden login is required", strings.TrimSpace(err.Error()) + case errors.Is(err, ErrBWLocked): + return "bitwarden vault is locked or BW_SESSION is missing", strings.TrimSpace(err.Error()) + case errors.Is(err, ErrBWUnavailable): + return "bitwarden CLI is unavailable", strings.TrimSpace(err.Error()) + case errors.Is(err, ErrBackendUnavailable): + return "secret backend is unavailable", strings.TrimSpace(err.Error()) + default: + return "secret backend preflight failed", strings.TrimSpace(err.Error()) + } +} + +func manifestSourceLabel(source string) string { + trimmed := strings.TrimSpace(source) + if trimmed == "" { + return DefaultManifestSource + } + return trimmed +} + +func normalizeStatusPolicy(policy BackendPolicy) string { + trimmed := strings.TrimSpace(string(policy)) + if trimmed == "" { + return string(BackendAuto) + } + return trimmed +} diff --git a/secretstore/runtime_test.go b/secretstore/runtime_test.go new file mode 100644 index 0000000..3daf05b --- /dev/null +++ b/secretstore/runtime_test.go @@ -0,0 +1,155 @@ +package secretstore + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) { + desc, err := DescribeRuntime(DescribeRuntimeOptions{ + ServiceName: "graylog-mcp", + LookupEnv: func(string) (string, bool) { return "", false }, + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)}, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("DescribeRuntime returned error: %v", err) + } + + if desc.ManifestSource == "" { + t.Fatal("ManifestSource should not be empty") + } + if desc.DeclaredPolicy != BackendEnvOnly { + t.Fatalf("DeclaredPolicy = %q, want %q", desc.DeclaredPolicy, BackendEnvOnly) + } + if desc.EffectivePolicy != BackendEnvOnly { + t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendEnvOnly) + } + if desc.DisplayName == "" { + t.Fatal("DisplayName should not be empty") + } + if !desc.Ready { + t.Fatalf("Ready = %v, want true", desc.Ready) + } + if desc.ReadyError != nil { + t.Fatalf("ReadyError = %v, want nil", desc.ReadyError) + } +} + +func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(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") + } + }) + + 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 false", desc.Ready) + } + if !errors.Is(desc.ReadyError, ErrBWLocked) { + t.Fatalf("ReadyError = %v, want ErrBWLocked", desc.ReadyError) + } + if !strings.Contains(desc.ReadyError.Error(), "set -x BW_SESSION (bw unlock --raw)") { + t.Fatalf("ReadyError = %v, want fish remediation", desc.ReadyError) + } +} + +func TestPreflightFromManifestReturnsTypedStatusAndRemediation(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + switch { + case len(args) == 1 && args[0] == "--version": + return []byte("2026.1.0\n"), nil + case len(args) == 1 && args[0] == "status": + return []byte(`{"status":"locked"}`), nil + default: + return nil, errors.New("unexpected bitwarden invocation") + } + }) + + report, err := PreflightFromManifest(PreflightOptions{ + ServiceName: "graylog-mcp", + Shell: "fish", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)}, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("PreflightFromManifest returned error: %v", err) + } + + if report.Status != PreflightStatusFail { + t.Fatalf("Status = %q, want %q", report.Status, PreflightStatusFail) + } + if !strings.Contains(strings.ToLower(report.Summary), "locked") { + t.Fatalf("Summary = %q, want lock hint", report.Summary) + } + if !strings.Contains(report.Remediation, "set -x BW_SESSION (bw unlock --raw)") { + t.Fatalf("Remediation = %q, want fish remediation", report.Remediation) + } +} + +func TestFormatBackendStatusIncludesDeclaredEffectiveAndReadiness(t *testing.T) { + line := FormatBackendStatus(RuntimeDescription{ + ManifestSource: "/opt/graylog-mcp/mcp.toml", + DeclaredPolicy: BackendBitwardenCLI, + EffectivePolicy: BackendBitwardenCLI, + DisplayName: "Bitwarden CLI", + Ready: false, + ReadyError: ErrBWLocked, + }) + + for _, needle := range []string{ + "declared=bitwarden-cli", + "effective=bitwarden-cli", + "display=Bitwarden CLI", + "ready=false", + "source=/opt/graylog-mcp/mcp.toml", + "error=", + } { + if !strings.Contains(line, needle) { + t.Fatalf("line = %q, want substring %q", line, needle) + } + } +} diff --git a/secretstore/store.go b/secretstore/store.go index 2b9bb5f..7b1b787 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -37,6 +37,7 @@ type Options struct { KWalletAppID string KWalletFolder string BitwardenCommand string + Shell string } type Store interface { -- 2.45.2 From 98f07f557db3b6b8ace4e9dd6cccee8167d621d1 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 11:18:14 +0200 Subject: [PATCH 39/58] feat(secretstore): add animated bitwarden wait loader --- secretstore/bitwarden.go | 116 ++++++++++++++++++++++++++++++++++ secretstore/bitwarden_test.go | 39 ++++++++++++ 2 files changed, 155 insertions(+) diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index ffcc0f7..acb7f27 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -10,6 +10,9 @@ import ( "path/filepath" "runtime" "strings" + "sync" + "sync/atomic" + "time" ) const ( @@ -18,11 +21,19 @@ const ( 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) var runBitwardenCLI bitwardenRunner = executeBitwardenCLI +var bitwardenLoaderActive atomic.Bool type bitwardenStore struct { command string @@ -511,6 +522,9 @@ func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName } func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { + stopLoader := startBitwardenLoader() + defer stopLoader() + cmd := exec.Command(command, args...) if stdin != nil { cmd.Stdin = bytes.NewReader(stdin) @@ -528,6 +542,108 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, return stdout.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) diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index e12a662..9a3e7e6 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -7,8 +7,10 @@ import ( "fmt" "os/exec" "path/filepath" + "regexp" "strings" "testing" + "unicode/utf8" "gitea.lclr.dev/AI/mcp-framework/manifest" ) @@ -365,6 +367,43 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) { } } +func TestBitwardenLoaderFrameUsesSingleLineRewriteAndMessage(t *testing.T) { + frame := bitwardenLoaderFrame(0) + if !strings.HasPrefix(frame, "\r\033[2K") { + t.Fatalf("frame prefix = %q, want carriage return + clear line", frame) + } + if !strings.Contains(frame, "\033[38;5;117mW") { + t.Fatalf("frame = %q, want highlighted first rune", frame) + } + cleaned := stripANSIControlSequences(frame) + if cleaned != bitwardenLoaderMessage { + t.Fatalf("cleaned frame = %q, want %q", cleaned, bitwardenLoaderMessage) + } +} + +func TestBitwardenLoaderFrameMovesAndWrapsTheWave(t *testing.T) { + firstFrame := bitwardenLoaderFrame(0) + secondFrame := bitwardenLoaderFrame(1) + if firstFrame == secondFrame { + t.Fatal("expected different frames between two ticks") + } + if !strings.Contains(secondFrame, "\033[38;5;117ma") { + t.Fatalf("second frame = %q, want highlighted second rune", secondFrame) + } + + wrapped := bitwardenLoaderFrame(utf8.RuneCountInString(bitwardenLoaderMessage)) + if !strings.Contains(wrapped, "\033[38;5;117mW") { + t.Fatalf("wrapped frame = %q, want wave to wrap to first rune", wrapped) + } +} + +var ansiControlSequencePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +func stripANSIControlSequences(value string) string { + noANSI := ansiControlSequencePattern.ReplaceAllString(value, "") + return strings.ReplaceAll(noANSI, "\r", "") +} + func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") -- 2.45.2 From 98bac84ab82097ddb22c67b7984ba0b81dcec58b Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 11:36:07 +0200 Subject: [PATCH 40/58] feat: add --debug tracing for bitwarden calls --- bootstrap/bootstrap.go | 36 +++++++++++++++- bootstrap/bootstrap_test.go | 71 ++++++++++++++++++++++++++++++++ cli/doctor.go | 2 + docs/bootstrap-cli.md | 2 + docs/secrets.md | 4 ++ secretstore/bitwarden.go | 77 ++++++++++++++++++++++++++++++++++- secretstore/bitwarden_test.go | 65 +++++++++++++++++++++++++++++ secretstore/manifest_open.go | 2 + secretstore/runtime.go | 2 + secretstore/store.go | 1 + 10 files changed, 259 insertions(+), 3 deletions(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index ea2ff2f..533952f 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -18,6 +18,8 @@ const ( CommandVersion = "version" CommandDoctor = "doctor" + bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG" + ConfigSubcommandShow = "show" ConfigSubcommandTest = "test" ConfigSubcommandDelete = "delete" @@ -118,7 +120,13 @@ func Run(ctx context.Context, opts Options) error { return ErrBinaryNameRequired } - command, commandArgs, showHelp := parseArgs(expandAliases(normalized.Args, normalized.Aliases)) + 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) } @@ -252,6 +260,25 @@ func parseArgs(args []string) (command string, commandArgs []string, showHelp bo 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 { @@ -512,6 +539,13 @@ func printGlobalHelp(opts Options) error { } } + if _, err := fmt.Fprintln(opts.Stdout, "\nOptions globales:"); err != nil { + return err + } + if _, err := fmt.Fprintln(opts.Stdout, " --debug Active le debug des appels Bitwarden."); err != nil { + return err + } + _, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help \n", opts.BinaryName) return err } diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index 02644e3..5e1f899 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "os" "slices" "strings" "testing" @@ -482,3 +483,73 @@ func TestRunPrintsDoctorAliasInGlobalHelpWhenEnabled(t *testing.T) { t.Fatalf("global help output missing default doctor alias details: %q", text) } } + +func TestRunAcceptsGlobalDebugFlagAndRoutesCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + var got Invocation + + t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "") + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"--debug", "setup", "--profile", "prod"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Setup: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != CommandSetup { + t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup) + } + wantArgs := []string{"--profile", "prod"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } + if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" { + t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1") + } +} + +func TestRunAcceptsGlobalDebugFlagAfterCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + var got Invocation + + t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "") + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"setup", "--debug", "--profile", "prod"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Setup: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != CommandSetup { + t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup) + } + wantArgs := []string{"--profile", "prod"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } + if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" { + t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1") + } +} diff --git a/cli/doctor.go b/cli/doctor.go index 293806e..10a508b 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -65,6 +65,7 @@ type DoctorOptions struct { type BitwardenDoctorOptions struct { Command string + Debug bool Shell string LookupEnv func(string) (string, bool) } @@ -238,6 +239,7 @@ 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, }) diff --git a/docs/bootstrap-cli.md b/docs/bootstrap-cli.md index 3bce155..74695ff 100644 --- a/docs/bootstrap-cli.md +++ b/docs/bootstrap-cli.md @@ -43,3 +43,5 @@ Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automati Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. +Le flag global `--debug` est supporté et active le debug des appels Bitwarden +(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`). diff --git a/docs/secrets.md b/docs/secrets.md index 24046d1..c1f5ee2 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -164,6 +164,10 @@ 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 diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index acb7f27..4d0b6f6 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -17,6 +18,7 @@ import ( const ( defaultBitwardenCommand = "bw" + bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG" bitwardenSessionEnvName = "BW_SESSION" bitwardenSecretFieldName = "mcp-secret" bitwardenServiceFieldName = "mcp-service" @@ -34,10 +36,12 @@ type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, var runBitwardenCLI bitwardenRunner = executeBitwardenCLI var bitwardenLoaderActive atomic.Bool +var bitwardenDebugOutput io.Writer = os.Stderr type bitwardenStore struct { command string serviceName string + debug bool } type bitwardenListItem struct { @@ -54,10 +58,12 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string if command == "" { command = defaultBitwardenCommand } + debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) store := &bitwardenStore{ command: command, serviceName: serviceName, + debug: debugEnabled, } if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { @@ -80,6 +86,7 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string if err := EnsureBitwardenReady(Options{ BitwardenCommand: command, + BitwardenDebug: debugEnabled, LookupEnv: options.LookupEnv, Shell: options.Shell, }); err != nil { @@ -99,6 +106,7 @@ func EnsureBitwardenReady(options Options) error { if command == "" { command = defaultBitwardenCommand } + debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) unlockCommand := bitwardenUnlockRemediation(command, options.Shell) lookupEnv := options.LookupEnv @@ -106,7 +114,7 @@ func EnsureBitwardenReady(options Options) error { lookupEnv = os.LookupEnv } - output, err := runBitwardenCLI(command, nil, "status") + output, err := runBitwardenCommand(command, debugEnabled, nil, "status") if err != nil { return fmt.Errorf("check bitwarden CLI status: %w", err) } @@ -438,7 +446,7 @@ func (s *bitwardenStore) encodePayload(payload map[string]any) (string, error) { } func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) { - output, err := runBitwardenCLI(s.command, stdin, args...) + output, err := runBitwardenCommand(s.command, s.debug, stdin, args...) if err != nil { return nil, fmt.Errorf("%s: %w", operation, err) } @@ -521,6 +529,71 @@ func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName 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 isBitwardenDebugEnabled(explicit bool) bool { + if explicit { + return true + } + + raw, ok := os.LookupEnv(bitwardenDebugEnvName) + if !ok { + return false + } + + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} + +func logBitwardenCommand(command string, args ...string) { + writer := bitwardenDebugOutput + if writer == nil { + return + } + + renderedArgs := sanitizeBitwardenDebugArgs(args) + if len(renderedArgs) == 0 { + _, _ = fmt.Fprintf(writer, "[bitwarden debug] %s\n", strings.TrimSpace(command)) + return + } + + _, _ = fmt.Fprintf( + writer, + "[bitwarden debug] %s %s\n", + strings.TrimSpace(command), + strings.Join(renderedArgs, " "), + ) +} + +func sanitizeBitwardenDebugArgs(args []string) []string { + if len(args) == 0 { + return nil + } + + rendered := make([]string, len(args)) + for idx, arg := range args { + rendered[idx] = strings.TrimSpace(arg) + } + + if len(rendered) >= 3 && rendered[0] == "create" && rendered[1] == "item" { + rendered[2] = "" + } + if len(rendered) >= 4 && rendered[0] == "edit" && rendered[1] == "item" { + rendered[3] = "" + } + + return rendered +} + func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { stopLoader := startBitwardenLoader() defer stopLoader() diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 9a3e7e6..b71e832 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -1,10 +1,12 @@ package secretstore import ( + "bytes" "encoding/base64" "encoding/json" "errors" "fmt" + "io" "os/exec" "path/filepath" "regexp" @@ -367,6 +369,59 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) { } } +func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1") + + var logs bytes.Buffer + withBitwardenDebugOutput(t, &logs) + + _, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + text := logs.String() + if !strings.Contains(text, "bw --version") { + t.Fatalf("debug logs = %q, want command bw --version", text) + } + if !strings.Contains(text, "bw status") { + t.Fatalf("debug logs = %q, want command bw status", text) + } +} + +func TestBitwardenDebugRedactsSensitivePayloadArguments(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + var logs bytes.Buffer + withBitwardenDebugOutput(t, &logs) + + store, err := Open(Options{ + ServiceName: "graylog-mcp", + BackendPolicy: BackendBitwardenCLI, + BitwardenDebug: true, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + + text := logs.String() + if !strings.Contains(text, "bw create item ") { + t.Fatalf("debug logs = %q, want redacted create payload", text) + } +} + func TestBitwardenLoaderFrameUsesSingleLineRewriteAndMessage(t *testing.T) { frame := bitwardenLoaderFrame(0) if !strings.HasPrefix(frame, "\r\033[2K") { @@ -455,6 +510,16 @@ func withBitwardenRunner( }) } +func withBitwardenDebugOutput(t *testing.T, writer io.Writer) { + t.Helper() + + previous := bitwardenDebugOutput + bitwardenDebugOutput = writer + t.Cleanup(func() { + bitwardenDebugOutput = previous + }) +} + type fakeBitwardenCLI struct { command string itemsByID map[string]fakeBitwardenItem diff --git a/secretstore/manifest_open.go b/secretstore/manifest_open.go index 9c95653..f8009d4 100644 --- a/secretstore/manifest_open.go +++ b/secretstore/manifest_open.go @@ -20,6 +20,7 @@ type OpenFromManifestOptions struct { KWalletAppID string KWalletFolder string BitwardenCommand string + BitwardenDebug bool Shell string ManifestLoader ManifestLoader ExecutableResolver ExecutableResolver @@ -38,6 +39,7 @@ func OpenFromManifest(options OpenFromManifestOptions) (Store, error) { KWalletAppID: options.KWalletAppID, KWalletFolder: options.KWalletFolder, BitwardenCommand: strings.TrimSpace(options.BitwardenCommand), + BitwardenDebug: options.BitwardenDebug, Shell: strings.TrimSpace(options.Shell), }) } diff --git a/secretstore/runtime.go b/secretstore/runtime.go index ec724dc..4d342d7 100644 --- a/secretstore/runtime.go +++ b/secretstore/runtime.go @@ -14,6 +14,7 @@ type DescribeRuntimeOptions struct { KWalletAppID string KWalletFolder string BitwardenCommand string + BitwardenDebug bool Shell string ManifestLoader ManifestLoader ExecutableResolver ExecutableResolver @@ -73,6 +74,7 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) KWalletAppID: options.KWalletAppID, KWalletFolder: options.KWalletFolder, BitwardenCommand: options.BitwardenCommand, + BitwardenDebug: options.BitwardenDebug, Shell: options.Shell, }) if openErr != nil { diff --git a/secretstore/store.go b/secretstore/store.go index 7b1b787..5d8d2a9 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -37,6 +37,7 @@ type Options struct { KWalletAppID string KWalletFolder string BitwardenCommand string + BitwardenDebug bool Shell string } -- 2.45.2 From 2920f5980ac77420b987ee1e3c0e2c1b191011ec Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 12:03:22 +0200 Subject: [PATCH 41/58] perf: reduce redundant bitwarden CLI calls --- scaffold/scaffold.go | 26 ++++++++++++++-- scaffold/scaffold_test.go | 2 ++ secretstore/bitwarden.go | 56 +++++++++++++++++------------------ secretstore/bitwarden_test.go | 36 +++++++++++++++++++++- 4 files changed, 89 insertions(+), 31 deletions(-) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 1856f3c..2d54d99 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -1537,6 +1537,7 @@ import ( "os" "path/filepath" "strings" + "sync" "gitea.lclr.dev/AI/mcp-framework/bootstrap" "gitea.lclr.dev/AI/mcp-framework/cli" @@ -1816,14 +1817,16 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er stdout = os.Stdout } + secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore) + report := cli.RunDoctor(ctx, cli.DoctorOptions{ ConfigCheck: cli.NewConfigCheck(r.ConfigStore), - SecretStoreCheck: cli.SecretStoreAvailabilityCheck(r.openSecretStore), + SecretStoreCheck: cli.SecretStoreAvailabilityCheck(secretStoreFactory), SecretBackendPolicy: r.activeBackendPolicy(), RequiredSecrets: []cli.DoctorSecret{ {Name: r.SecretName, Label: "API token"}, }, - SecretStoreFactory: r.openSecretStore, + SecretStoreFactory: secretStoreFactory, ManifestCheck: r.manifestDoctorCheck(), BitwardenOptions: cli.BitwardenDoctorOptions{ LookupEnv: os.LookupEnv, @@ -1841,6 +1844,25 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er return nil } +func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) { + if factory == nil { + return nil + } + + var ( + once sync.Once + store secretstore.Store + err error + ) + + return func() (secretstore.Store, error) { + once.Do(func() { + store, err = factory() + }) + return store, err + } +} + func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error { stdout := inv.Stdout if stdout == nil { diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 5880090..2e351eb 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -76,6 +76,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "ManifestSource", "ManifestCheck: r.manifestDoctorCheck()", "SecretBackendPolicy: r.activeBackendPolicy()", + "secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)", + "func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {", "cli.WriteSetupSecretVerified", } { if !strings.Contains(string(appGo), snippet) { diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 4d0b6f6..439b146 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -211,7 +211,7 @@ func detectShellFlavor(shellHint string) string { func (s *bitwardenStore) SetSecret(name, label, secret string) error { secretName := s.scopedName(name) - item, err := s.findItem(secretName, name) + item, payload, err := s.findItem(secretName, name) switch { case errors.Is(err, ErrNotFound): template, err := s.itemTemplate() @@ -239,11 +239,6 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { return err } - payload, err := s.itemByID(item.ID) - if err != nil { - return err - } - setBitwardenSecretPayload(payload, s.serviceName, name, secretName, label, secret) encoded, err := s.encodePayload(payload) if err != nil { @@ -266,12 +261,7 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { func (s *bitwardenStore) GetSecret(name string) (string, error) { secretName := s.scopedName(name) - item, err := s.findItem(secretName, name) - if err != nil { - return "", err - } - - payload, err := s.itemByID(item.ID) + _, payload, err := s.findItem(secretName, name) if err != nil { return "", err } @@ -286,7 +276,7 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) { func (s *bitwardenStore) DeleteSecret(name string) error { secretName := s.scopedName(name) - item, err := s.findItem(secretName, name) + item, _, err := s.findItem(secretName, name) if errors.Is(err, ErrNotFound) { return nil } @@ -311,7 +301,12 @@ func (s *bitwardenStore) scopedName(name string) string { return fmt.Sprintf("%s/%s", s.serviceName, name) } -func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenListItem, error) { +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, @@ -321,15 +316,15 @@ func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenLi secretName, ) if err != nil { - return bitwardenListItem{}, err + return bitwardenListItem{}, nil, err } if strings.TrimSpace(string(output)) == "" { - return bitwardenListItem{}, ErrNotFound + return bitwardenListItem{}, nil, ErrNotFound } var items []bitwardenListItem if err := json.Unmarshal(output, &items); err != nil { - return bitwardenListItem{}, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err) + return bitwardenListItem{}, nil, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err) } matches := make([]bitwardenListItem, 0, len(items)) @@ -344,42 +339,47 @@ func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenLi } if len(matches) == 0 { - return bitwardenListItem{}, ErrNotFound + return bitwardenListItem{}, nil, ErrNotFound } - markedMatches := make([]bitwardenListItem, 0, len(matches)) - legacyMatches := make([]bitwardenListItem, 0, len(matches)) + 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{}, err + return bitwardenListItem{}, nil, err + } + + resolved := bitwardenResolvedItem{ + item: item, + payload: payload, } if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) { - markedMatches = append(markedMatches, item) + markedMatches = append(markedMatches, resolved) continue } - legacyMatches = append(legacyMatches, item) + legacyMatches = append(legacyMatches, resolved) } switch len(markedMatches) { case 0: switch len(legacyMatches) { case 0: - return bitwardenListItem{}, ErrNotFound + return bitwardenListItem{}, nil, ErrNotFound case 1: - return legacyMatches[0], nil + return legacyMatches[0].item, legacyMatches[0].payload, nil default: - return bitwardenListItem{}, fmt.Errorf( + return bitwardenListItem{}, nil, fmt.Errorf( "multiple legacy bitwarden items match secret %q for service %q", secretName, s.serviceName, ) } case 1: - return markedMatches[0], nil + return markedMatches[0].item, markedMatches[0].payload, nil default: - return bitwardenListItem{}, fmt.Errorf( + return bitwardenListItem{}, nil, fmt.Errorf( "multiple bitwarden items share marker for secret %q and service %q", secretName, s.serviceName, diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index b71e832..5da8fe2 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -337,6 +337,38 @@ func TestBitwardenStoreFallsBackToSingleLegacyItemWithoutMarkers(t *testing.T) { } } +func TestBitwardenStoreGetSecretReadsSelectedItemOnlyOnce(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret = %q, want secret-v1", value) + } + if fakeCLI.getItemCalls != 1 { + t.Fatalf("bw get item count = %d, want 1", fakeCLI.getItemCalls) + } +} + func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) { store := &bitwardenStore{command: "bw", serviceName: "email-mcp"} withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { @@ -346,7 +378,7 @@ func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) { return nil, fmt.Errorf("unexpected args: %v", args) }) - _, err := store.findItem("email-mcp/api-token", "api-token") + _, _, err := store.findItem("email-mcp/api-token", "api-token") if !errors.Is(err, ErrNotFound) { t.Fatalf("error = %v, want ErrNotFound", err) } @@ -527,6 +559,7 @@ type fakeBitwardenCLI struct { status string versionChecked bool statusChecked bool + getItemCalls int } type fakeBitwardenItem struct { @@ -568,6 +601,7 @@ func (f *fakeBitwardenCLI) run(command string, stdin []byte, args ...string) ([] case len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search": return f.handleListItems(args[3]) case len(args) == 3 && args[0] == "get" && args[1] == "item": + f.getItemCalls++ return f.handleGetItem(args[2]) case len(args) == 3 && args[0] == "get" && args[1] == "template" && args[2] == "item": return []byte(`{"type":2,"name":"","notes":"","secureNote":{"type":0},"fields":[]}`), nil -- 2.45.2 From 6e80d3418ec6dc2a6d5289c49ee8943b2598db10 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 12:38:58 +0200 Subject: [PATCH 42/58] feat: add bitwarden login flow with persisted BW_SESSION --- bootstrap/bootstrap.go | 9 + bootstrap/bootstrap_test.go | 54 ++++++ docs/bootstrap-cli.md | 4 + docs/packages.md | 2 +- docs/secrets.md | 30 +++- scaffold/scaffold.go | 48 +++++- scaffold/scaffold_test.go | 4 + secretstore/bitwarden.go | 116 ++++++++++--- secretstore/bitwarden_session.go | 197 ++++++++++++++++++++++ secretstore/bitwarden_session_test.go | 231 ++++++++++++++++++++++++++ 10 files changed, 673 insertions(+), 22 deletions(-) create mode 100644 secretstore/bitwarden_session.go create mode 100644 secretstore/bitwarden_session_test.go diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 533952f..13fdd57 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -12,6 +12,7 @@ import ( const ( CommandSetup = "setup" + CommandLogin = "login" CommandMCP = "mcp" CommandConfig = "config" CommandUpdate = "update" @@ -38,6 +39,7 @@ type Handler func(context.Context, Invocation) error type Hooks struct { Setup Handler + Login Handler MCP Handler Config Handler ConfigShow Handler @@ -83,6 +85,13 @@ var commands = []commandDef{ 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.", diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index 5e1f899..ca2c34a 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -41,6 +41,37 @@ func TestRunRoutesSetupHook(t *testing.T) { } } +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 @@ -215,6 +246,29 @@ func TestRunPrintsCommandHelp(t *testing.T) { } } +func TestRunPrintsLoginCommandHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"help", "login"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp login [args]") { + t.Fatalf("command help output = %q", text) + } + if !strings.Contains(strings.ToLower(text), "bitwarden") { + t.Fatalf("command help output missing bitwarden description: %q", text) + } +} + func TestRunPrintsConfigHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/docs/bootstrap-cli.md b/docs/bootstrap-cli.md index 74695ff..403de43 100644 --- a/docs/bootstrap-cli.md +++ b/docs/bootstrap-cli.md @@ -18,6 +18,9 @@ func main() { Setup: func(ctx context.Context, inv bootstrap.Invocation) error { return runSetup(ctx, inv.Args) }, + Login: func(ctx context.Context, inv bootstrap.Invocation) error { + return runLogin(ctx, inv.Args) + }, MCP: func(ctx context.Context, inv bootstrap.Invocation) error { return runMCP(ctx, inv.Args) }, @@ -41,6 +44,7 @@ func main() { Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`. Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). +La commande `login` est optionnelle et peut être branchée pour gérer un unlock Bitwarden interactif. La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. Le flag global `--debug` est supporté et active le debug des appels Bitwarden diff --git a/docs/packages.md b/docs/packages.md index 1e197e2..beec9cb 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -1,6 +1,6 @@ # Packages -- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. +- `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. diff --git a/docs/secrets.md b/docs/secrets.md index c1f5ee2..07d527e 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -91,6 +91,34 @@ if err := secretstore.EnsureBitwardenReady(secretstore.Options{ } ``` +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: "email-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: "email-mcp", +}) +if err != nil { + return err +} +fmt.Println("session restaurée depuis disque:", loaded) +``` + Pour stocker un secret structuré en JSON : ```go @@ -174,7 +202,7 @@ Les commandes `bw` exécutées seront affichées (avec redaction des payloads se bw status ``` -2. Déverrouiller le vault et exporter `BW_SESSION` : +2. Déverrouiller le vault et exporter `BW_SESSION` (ou utiliser `LoginBitwarden`) : - Bash/Zsh : diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 2d54d99..9723a58 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -1659,6 +1659,7 @@ func (r Runtime) Run(ctx context.Context, args []string) error { Args: args, Hooks: bootstrap.Hooks{ Setup: r.runSetup, + Login: r.runLogin, MCP: r.runMCP, ConfigShow: r.runConfigShow, ConfigTest: r.runConfigTest, @@ -1751,6 +1752,42 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { return err } +func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error { + if r.activeBackendPolicy() != secretstore.BackendBitwardenCLI { + return fmt.Errorf( + "commande login disponible uniquement avec secret_store.backend_policy=%q", + secretstore.BackendBitwardenCLI, + ) + } + + stdin := inv.Stdin + if stdin == nil { + stdin = os.Stdin + } + + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + stderr := inv.Stderr + if stderr == nil { + stderr = os.Stderr + } + + if _, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ + ServiceName: r.BinaryName, + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + }); err != nil { + return err + } + + _, err := fmt.Fprintf(stdout, "Session Bitwarden persistée pour %q.\n", r.BinaryName) + return err +} + func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error { stdout := inv.Stdout if stdout == nil { @@ -1878,9 +1915,18 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error } func (r Runtime) openSecretStore() (secretstore.Store, error) { + policy := r.activeBackendPolicy() + if policy == secretstore.BackendBitwardenCLI { + if _, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ + ServiceName: r.BinaryName, + }); err != nil { + return nil, err + } + } + return secretstore.Open(secretstore.Options{ ServiceName: r.BinaryName, - BackendPolicy: r.activeBackendPolicy(), + BackendPolicy: policy, LookupEnv: func(name string) (string, bool) { if name == r.SecretName { return os.LookupEnv(r.TokenEnv) diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 2e351eb..d67353f 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -67,6 +67,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "config.NewStore[Profile]", "secretstore.Open(secretstore.Options", + "secretstore.EnsureBitwardenSessionEnv", + "secretstore.LoginBitwarden", "update.Run", "manifest.LoadDefaultOrEmbedded", "bootstrap.Run", @@ -76,6 +78,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "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", diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 439b146..25765ca 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -33,8 +33,15 @@ const ( ) 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 bitwardenLoaderActive atomic.Bool var bitwardenDebugOutput io.Writer = os.Stderr @@ -66,6 +73,17 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string debug: debugEnabled, } + 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), + ) + } + if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { if errors.Is(err, exec.ErrNotFound) { return nil, fmt.Errorf( @@ -109,27 +127,12 @@ func EnsureBitwardenReady(options Options) error { debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) unlockCommand := bitwardenUnlockRemediation(command, options.Shell) - lookupEnv := options.LookupEnv - if lookupEnv == nil { - lookupEnv = os.LookupEnv - } - - output, err := runBitwardenCommand(command, debugEnabled, nil, "status") + status, err := readBitwardenStatus(command, debugEnabled) if err != nil { - return fmt.Errorf("check bitwarden CLI status: %w", err) + return 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)) - } - - switch strings.ToLower(strings.TrimSpace(status.Status)) { + switch status { case "unauthenticated": return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn) case "locked": @@ -139,7 +142,15 @@ func EnsureBitwardenReady(options Options) error { 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", @@ -153,11 +164,30 @@ func EnsureBitwardenReady(options Options) error { return fmt.Errorf( "%w: unsupported bitwarden status %q", ErrBWUnavailable, - strings.TrimSpace(status.Status), + 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)) @@ -536,6 +566,19 @@ func runBitwardenCommand(command string, debug bool, stdin []byte, args ...strin 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 @@ -615,6 +658,41 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, return stdout.Bytes(), nil } +func executeBitwardenCLIInteractive( + command string, + stdin io.Reader, + stdout, stderr io.Writer, + args ...string, +) ([]byte, error) { + stopLoader := startBitwardenLoader() + defer stopLoader() + + 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() {} diff --git a/secretstore/bitwarden_session.go b/secretstore/bitwarden_session.go new file mode 100644 index 0000000..ff4d814 --- /dev/null +++ b/secretstore/bitwarden_session.go @@ -0,0 +1,197 @@ +package secretstore + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const bitwardenSessionFileName = "bw-session" + +var bitwardenUserConfigDir = os.UserConfigDir + +type BitwardenSessionOptions struct { + ServiceName string +} + +type BitwardenLoginOptions struct { + ServiceName string + BitwardenCommand string + BitwardenDebug bool + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +func SaveBitwardenSession(options BitwardenSessionOptions, session string) (string, error) { + trimmedSession := strings.TrimSpace(session) + if trimmedSession == "" { + return "", errors.New("bitwarden session must not be empty") + } + + path, err := resolveBitwardenSessionPath(options) + if err != nil { + return "", err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create bitwarden session dir %q: %w", dir, err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return "", fmt.Errorf("set bitwarden session dir permissions %q: %w", dir, err) + } + + tmpFile, err := os.CreateTemp(dir, "bw-session-*.tmp") + if err != nil { + return "", fmt.Errorf("create temp bitwarden session in %q: %w", dir, err) + } + + tmpPath := tmpFile.Name() + cleanup := true + defer func() { + _ = tmpFile.Close() + if cleanup { + _ = os.Remove(tmpPath) + } + }() + + if err := tmpFile.Chmod(0o600); err != nil { + return "", fmt.Errorf("set bitwarden session temp file permissions %q: %w", tmpPath, err) + } + if _, err := tmpFile.WriteString(trimmedSession + "\n"); err != nil { + return "", fmt.Errorf("write bitwarden session temp file %q: %w", tmpPath, err) + } + if err := tmpFile.Close(); err != nil { + return "", fmt.Errorf("close bitwarden session temp file %q: %w", tmpPath, err) + } + if err := os.Rename(tmpPath, path); err != nil { + return "", fmt.Errorf("replace bitwarden session file %q: %w", path, err) + } + if err := os.Chmod(path, 0o600); err != nil { + return "", fmt.Errorf("set bitwarden session file permissions %q: %w", path, err) + } + + cleanup = false + return path, nil +} + +func LoadBitwardenSession(options BitwardenSessionOptions) (string, error) { + path, err := resolveBitwardenSessionPath(options) + if err != nil { + return "", err + } + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", ErrNotFound + } + return "", fmt.Errorf("read bitwarden session file %q: %w", path, err) + } + + session := strings.TrimSpace(string(data)) + if session == "" { + return "", ErrNotFound + } + + return session, nil +} + +func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) { + if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" { + return false, nil + } + + session, err := LoadBitwardenSession(options) + if err != nil { + if errors.Is(err, ErrNotFound) { + return false, nil + } + return false, err + } + + if err := os.Setenv(bitwardenSessionEnvName, session); err != nil { + return false, fmt.Errorf("set %s from persisted bitwarden session: %w", bitwardenSessionEnvName, err) + } + + return true, nil +} + +func LoginBitwarden(options BitwardenLoginOptions) (string, error) { + serviceName := strings.TrimSpace(options.ServiceName) + if serviceName == "" { + return "", errors.New("service name must not be empty") + } + + command := strings.TrimSpace(options.BitwardenCommand) + if command == "" { + command = defaultBitwardenCommand + } + debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) + + stdin := options.Stdin + if stdin == nil { + stdin = os.Stdin + } + stdout := options.Stdout + if stdout == nil { + stdout = os.Stdout + } + stderr := options.Stderr + if stderr == nil { + stderr = os.Stderr + } + + status, err := readBitwardenStatus(command, debugEnabled) + if err != nil { + return "", err + } + + switch status { + case "unauthenticated": + if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil { + return "", fmt.Errorf("login to bitwarden CLI: %w", err) + } + case "locked", "unlocked": + default: + return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status) + } + + unlockOutput, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, nil, stderr, "unlock", "--raw") + if err != nil { + return "", fmt.Errorf("unlock bitwarden vault: %w", err) + } + + session := strings.TrimSpace(string(unlockOutput)) + if session == "" { + return "", errors.New("bitwarden CLI returned an empty session after unlock") + } + + if err := os.Setenv(bitwardenSessionEnvName, session); err != nil { + return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err) + } + + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil { + return "", fmt.Errorf("persist bitwarden session: %w", err) + } + + return session, nil +} + +func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) { + serviceName := strings.TrimSpace(options.ServiceName) + if serviceName == "" { + return "", errors.New("service name must not be empty") + } + + userConfigDir, err := bitwardenUserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve user config dir for bitwarden session: %w", err) + } + + return filepath.Join(userConfigDir, serviceName, bitwardenSessionFileName), nil +} diff --git a/secretstore/bitwarden_session_test.go b/secretstore/bitwarden_session_test.go new file mode 100644 index 0000000..3b8e3e4 --- /dev/null +++ b/secretstore/bitwarden_session_test.go @@ -0,0 +1,231 @@ +package secretstore + +import ( + "errors" + "fmt" + "io" + "os" + "slices" + "testing" +) + +func TestLoginBitwardenRunsInteractiveFlowAndPersistsSession(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"unauthenticated"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + var calls [][]string + withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { + calls = append(calls, slices.Clone(args)) + switch { + case len(args) == 1 && args[0] == "login": + return nil, nil + case len(args) == 2 && args[0] == "unlock" && args[1] == "--raw": + return []byte("persisted-session\n"), nil + default: + return nil, fmt.Errorf("unexpected interactive args: %v", args) + } + }) + + session, err := LoginBitwarden(BitwardenLoginOptions{ + ServiceName: "email-mcp", + BitwardenCommand: "bw", + }) + if err != nil { + t.Fatalf("LoginBitwarden returned error: %v", err) + } + if session != "persisted-session" { + t.Fatalf("session = %q, want persisted-session", session) + } + if got := os.Getenv("BW_SESSION"); got != "persisted-session" { + t.Fatalf("BW_SESSION = %q, want persisted-session", got) + } + + if len(calls) != 2 { + t.Fatalf("interactive call count = %d, want 2", len(calls)) + } + if !slices.Equal(calls[0], []string{"login"}) { + t.Fatalf("interactive call #1 args = %v, want [login]", calls[0]) + } + if !slices.Equal(calls[1], []string{"unlock", "--raw"}) { + t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1]) + } + + persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if err != nil { + t.Fatalf("LoadBitwardenSession returned error: %v", err) + } + if persisted != "persisted-session" { + t.Fatalf("persisted session = %q, want persisted-session", persisted) + } +} + +func TestLoginBitwardenSkipsInteractiveLoginWhenAlreadyAuthenticated(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"locked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + var calls [][]string + withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { + calls = append(calls, slices.Clone(args)) + if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" { + return []byte("session-locked\n"), nil + } + return nil, fmt.Errorf("unexpected interactive args: %v", args) + }) + + session, err := LoginBitwarden(BitwardenLoginOptions{ + ServiceName: "email-mcp", + BitwardenCommand: "bw", + }) + if err != nil { + t.Fatalf("LoginBitwarden returned error: %v", err) + } + if session != "session-locked" { + t.Fatalf("session = %q, want session-locked", session) + } + + if len(calls) != 1 { + t.Fatalf("interactive call count = %d, want 1", len(calls)) + } + if !slices.Equal(calls[0], []string{"unlock", "--raw"}) { + t.Fatalf("interactive call args = %v, want [unlock --raw]", calls[0]) + } +} + +func TestBitwardenSessionEnvLoadsFromPersistedSession(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + path, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session") + if err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat persisted session file: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("session file mode = %o, want 600", got) + } + + loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if err != nil { + t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err) + } + if !loaded { + t.Fatal("expected session to be loaded from persisted file") + } + if got := os.Getenv("BW_SESSION"); got != "persisted-session" { + t.Fatalf("BW_SESSION = %q, want persisted-session", got) + } +} + +func TestBitwardenSessionEnvDoesNotOverrideExistingValue(t *testing.T) { + t.Setenv("BW_SESSION", "from-env") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session"); err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if err != nil { + t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err) + } + if loaded { + t.Fatal("expected existing BW_SESSION to be kept") + } + if got := os.Getenv("BW_SESSION"); got != "from-env" { + t.Fatalf("BW_SESSION = %q, want from-env", got) + } +} + +func TestOpenBitwardenCLILoadsPersistedSessionWhenEnvironmentIsMissing(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-open-session"); err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + if _, ok := store.(*bitwardenStore); !ok { + t.Fatalf("store type = %T, want *bitwardenStore", store) + } + if got := os.Getenv("BW_SESSION"); got != "persisted-open-session" { + t.Fatalf("BW_SESSION = %q, want persisted-open-session", got) + } +} + +func TestLoadBitwardenSessionReturnsNotFoundWhenFileMissing(t *testing.T) { + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + _, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("error = %v, want ErrNotFound", err) + } +} + +func withBitwardenInteractiveRunner( + t *testing.T, + runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error), +) { + t.Helper() + + previous := runBitwardenInteractiveCLI + runBitwardenInteractiveCLI = runner + t.Cleanup(func() { + runBitwardenInteractiveCLI = previous + }) +} + +func withBitwardenUserConfigDir(t *testing.T, resolver func() (string, error)) { + t.Helper() + + previous := bitwardenUserConfigDir + bitwardenUserConfigDir = resolver + t.Cleanup(func() { + bitwardenUserConfigDir = previous + }) +} -- 2.45.2 From 017005b0b1033e518ea7b243d0ff982039712de7 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 14:24:43 +0200 Subject: [PATCH 43/58] fix(secretstore): disable loader during interactive bitwarden prompts --- secretstore/bitwarden.go | 6 ++---- secretstore/bitwarden_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 25765ca..362c019 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -42,6 +42,7 @@ type bitwardenInteractiveRunner func( var runBitwardenCLI bitwardenRunner = executeBitwardenCLI var runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive +var startBitwardenLoaderFunc = startBitwardenLoader var bitwardenLoaderActive atomic.Bool var bitwardenDebugOutput io.Writer = os.Stderr @@ -638,7 +639,7 @@ func sanitizeBitwardenDebugArgs(args []string) []string { } func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { - stopLoader := startBitwardenLoader() + stopLoader := startBitwardenLoaderFunc() defer stopLoader() cmd := exec.Command(command, args...) @@ -664,9 +665,6 @@ func executeBitwardenCLIInteractive( stdout, stderr io.Writer, args ...string, ) ([]byte, error) { - stopLoader := startBitwardenLoader() - defer stopLoader() - cmd := exec.Command(command, args...) if stdin != nil { cmd.Stdin = stdin diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 5da8fe2..4b64c57 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "os" "os/exec" "path/filepath" "regexp" @@ -484,6 +485,28 @@ func TestBitwardenLoaderFrameMovesAndWrapsTheWave(t *testing.T) { } } +func TestExecuteBitwardenCLIInteractiveSkipsLoader(t *testing.T) { + loaderStartCount := 0 + withBitwardenLoaderStarter(t, func() func() { + loaderStartCount++ + return func() {} + }) + + _, err := executeBitwardenCLIInteractive( + os.Args[0], + nil, + io.Discard, + io.Discard, + "-test.run=^$", + ) + if err != nil { + t.Fatalf("executeBitwardenCLIInteractive returned error: %v", err) + } + if loaderStartCount != 0 { + t.Fatalf("loader start count = %d, want 0 for interactive command", loaderStartCount) + } +} + var ansiControlSequencePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) func stripANSIControlSequences(value string) string { @@ -552,6 +575,16 @@ func withBitwardenDebugOutput(t *testing.T, writer io.Writer) { }) } +func withBitwardenLoaderStarter(t *testing.T, starter func() func()) { + t.Helper() + + previous := startBitwardenLoaderFunc + startBitwardenLoaderFunc = starter + t.Cleanup(func() { + startBitwardenLoaderFunc = previous + }) +} + type fakeBitwardenCLI struct { command string itemsByID map[string]fakeBitwardenItem -- 2.45.2 From ef22b1aa8a351d61835581a14a9ec896fe1ad93a Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 17:52:50 +0200 Subject: [PATCH 44/58] fix: prompt login in red when bitwarden session is missing --- scaffold/scaffold.go | 40 ++++++++++++++++++++++++++++++++++++--- scaffold/scaffold_test.go | 2 ++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 9723a58..ca72024 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -1596,6 +1596,11 @@ type Runtime struct { SecretName string } +const ( + ansiRedColor = "\033[31m" + ansiResetColor = "\033[0m" +) + func Run(ctx context.Context, args []string, version string) error { runtime, err := NewRuntime(version) if err != nil { @@ -1917,9 +1922,7 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error func (r Runtime) openSecretStore() (secretstore.Store, error) { policy := r.activeBackendPolicy() if policy == secretstore.BackendBitwardenCLI { - if _, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ - ServiceName: r.BinaryName, - }); err != nil { + if err := r.ensureBitwardenSession(); err != nil { return nil, err } } @@ -1936,6 +1939,37 @@ func (r Runtime) openSecretStore() (secretstore.Store, error) { }) } +func (r Runtime) ensureBitwardenSession() error { + if hasBitwardenSessionInEnv() { + return nil + } + + loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ + ServiceName: r.BinaryName, + }) + if err != nil { + return err + } + + if loaded || hasBitwardenSessionInEnv() { + return nil + } + + return errors.New(colorizeRed(fmt.Sprintf( + "Session Bitwarden introuvable. Lance %s login puis relance la commande.", + r.BinaryName, + ))) +} + +func hasBitwardenSessionInEnv() bool { + session, ok := os.LookupEnv("BW_SESSION") + return ok && strings.TrimSpace(session) != "" +} + +func colorizeRed(message string) string { + return ansiRedColor + strings.TrimSpace(message) + ansiResetColor +} + func (r Runtime) activeBackendPolicy() secretstore.BackendPolicy { policy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy)) if policy == "" { diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index d67353f..bd32a49 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -68,6 +68,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "config.NewStore[Profile]", "secretstore.Open(secretstore.Options", "secretstore.EnsureBitwardenSessionEnv", + "func (r Runtime) ensureBitwardenSession() error {", + "\\033[31m", "secretstore.LoginBitwarden", "update.Run", "manifest.LoadDefaultOrEmbedded", -- 2.45.2 From 20b5026f9d25d55c821c1f4de6e30aba41473829 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 11:46:47 +0200 Subject: [PATCH 45/58] feat: add manifest code generation --- README.md | 2 + cmd/mcp-framework/main.go | 68 ++++++++++- cmd/mcp-framework/main_test.go | 40 ++++++ docs/README.md | 1 + docs/generate.md | 59 +++++++++ generate/generate.go | 214 +++++++++++++++++++++++++++++++++ generate/generate_test.go | 213 ++++++++++++++++++++++++++++++++ 7 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 docs/generate.md create mode 100644 generate/generate.go create mode 100644 generate/generate_test.go diff --git a/README.md b/README.md index e9b682d..800642b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - Le framework fournit des briques réutilisables : config locale, secrets, résolution CLI, manifeste projet, et auto-update. - Il peut être utilisé de manière modulaire (package par package) ou avec un bootstrap CLI prêt à l'emploi. - Il inclut un générateur de squelette (`mcp-framework scaffold init`) pour démarrer un nouveau binaire MCP rapidement. +- Il peut générer la glue Go dérivée d'un manifeste racine (`mcp-framework generate`). - Toute la documentation détaillée est maintenant organisée dans `docs/` par grandes parties. ## Démarrage rapide @@ -43,6 +44,7 @@ go run ./cmd/my-mcp help - Packages : [docs/packages.md](docs/packages.md) - Bootstrap CLI : [docs/bootstrap-cli.md](docs/bootstrap-cli.md) - Manifeste `mcp.toml` : [docs/manifest.md](docs/manifest.md) +- Génération depuis `mcp.toml` : [docs/generate.md](docs/generate.md) - Scaffolding : [docs/scaffolding.md](docs/scaffolding.md) - Config JSON : [docs/config.md](docs/config.md) - Secrets : [docs/secrets.md](docs/secrets.md) diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go index a4a263f..bcc9345 100644 --- a/cmd/mcp-framework/main.go +++ b/cmd/mcp-framework/main.go @@ -6,8 +6,10 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" + generatepkg "gitea.lclr.dev/AI/mcp-framework/generate" scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold" ) @@ -34,6 +36,8 @@ func run(args []string, stdout, stderr io.Writer) error { } switch args[0] { + case "generate": + return runGenerate(args[1:], stdout, stderr) case "scaffold": return runScaffold(args[1:], stdout, stderr) default: @@ -41,6 +45,60 @@ func run(args []string, stdout, stderr io.Writer) error { } } +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) @@ -145,12 +203,20 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error { func printGlobalHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", + "Usage:\n %s [options]\n\nCommands:\n generate Génère la glue Go depuis mcp.toml\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", toolName, toolName, ) } +func printGenerateHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s generate [flags]\n\nFlags:\n --manifest Chemin du mcp.toml à lire\n --package-dir Répertoire du package Go généré (défaut: mcpgen)\n --package-name Nom du package Go généré (défaut: dérivé du dossier)\n --check Vérifie que les fichiers générés sont à jour\n", + toolName, + ) +} + func printScaffoldHelp(w io.Writer) { fmt.Fprintf( w, diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go index 0b17192..7468085 100644 --- a/cmd/mcp-framework/main_test.go +++ b/cmd/mcp-framework/main_test.go @@ -60,6 +60,46 @@ func TestRunScaffoldInitCreatesProject(t *testing.T) { } } +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 diff --git a/docs/README.md b/docs/README.md index 83fedb6..4b0360e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Cette documentation est organisée par grandes parties pour séparer la vue d'en - [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) diff --git a/docs/generate.md b/docs/generate.md new file mode 100644 index 0000000..6f9ea58 --- /dev/null +++ b/docs/generate.md @@ -0,0 +1,59 @@ +# 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. Le premier usage couvert est le loader de +manifeste embarqué. + +## Usage + +Depuis la racine du projet consommateur : + +```bash +mcp-framework generate +``` + +La commande lit `./mcp.toml`, valide son contenu avec le package `manifest`, et +génère : + +```text +mcpgen/ + manifest.go +``` + +Le fichier généré expose : + +```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. + +## 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 +``` + +## Migration d'un wrapper manuel + +Pour remplacer un wrapper local du type `internal/manifest` : + +1. Déplacer le manifeste projet vers `mcp.toml` à la racine. +2. Lancer `mcp-framework generate`. +3. Remplacer les imports du wrapper local par le package généré, par exemple + `example.com/my-mcp/mcpgen`. +4. Remplacer les appels `manifest.Load(...)` du wrapper par + `mcpgen.LoadManifest(...)`. +5. Supprimer l'ancien wrapper manuel. + +Après génération, un simple `go build ./...` suffit. La compilation ne dépend +pas de la commande `mcp-framework`. diff --git a/generate/generate.go b/generate/generate.go new file mode 100644 index 0000000..7d4a497 --- /dev/null +++ b/generate/generate.go @@ -0,0 +1,214 @@ +package generate + +import ( + "bytes" + "errors" + "fmt" + "go/format" + "go/token" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +var ErrGeneratedFilesOutdated = errors.New("generated files are not up to date") + +type Options struct { + ProjectDir string + ManifestPath string + PackageDir string + PackageName string + Check bool +} + +type Result struct { + Root string + Files []string +} + +func Generate(options Options) (Result, error) { + normalized, err := normalizeOptions(options) + if err != nil { + return Result{}, err + } + + if _, err := manifest.Load(normalized.ManifestPath); 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) + } + + content, err := renderManifestLoader(normalized.PackageName, string(manifestContent)) + if err != nil { + return Result{}, err + } + + files := []generatedFile{ + { + Path: filepath.Join(normalized.PackageDir, "manifest.go"), + Content: content, + Mode: 0o644, + }, + } + + written := make([]string, 0, len(files)) + for _, file := range files { + target := filepath.Join(normalized.ProjectDir, file.Path) + if normalized.Check { + current, err := os.ReadFile(target) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path) + } + return Result{}, fmt.Errorf("read generated file %q: %w", target, err) + } + if !bytes.Equal(current, []byte(file.Content)) { + return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path) + } + written = append(written, file.Path) + continue + } + + if err := writeGeneratedFile(target, file.Content, file.Mode); err != nil { + return Result{}, err + } + written = append(written, file.Path) + } + + sort.Strings(written) + return Result{ + Root: normalized.ProjectDir, + Files: written, + }, nil +} + +type normalizedOptions struct { + ProjectDir string + ManifestPath string + PackageDir string + PackageName string + Check bool +} + +type generatedFile struct { + Path string + Content string + Mode os.FileMode +} + +func normalizeOptions(options Options) (normalizedOptions, error) { + manifestPath := strings.TrimSpace(options.ManifestPath) + projectDir := strings.TrimSpace(options.ProjectDir) + + if manifestPath == "" { + baseDir := projectDir + if baseDir == "" { + wd, err := os.Getwd() + if err != nil { + return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err) + } + baseDir = wd + } + manifestPath = filepath.Join(baseDir, manifest.DefaultFile) + } else if !filepath.IsAbs(manifestPath) { + baseDir := projectDir + if baseDir == "" { + wd, err := os.Getwd() + if err != nil { + return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err) + } + baseDir = wd + } + manifestPath = filepath.Join(baseDir, manifestPath) + } + + resolvedManifest, err := filepath.Abs(manifestPath) + if err != nil { + return normalizedOptions{}, fmt.Errorf("resolve manifest path %q: %w", manifestPath, err) + } + + if projectDir == "" { + projectDir = filepath.Dir(resolvedManifest) + } + resolvedProjectDir, err := filepath.Abs(projectDir) + if err != nil { + return normalizedOptions{}, fmt.Errorf("resolve project dir %q: %w", projectDir, err) + } + + packageDir := filepath.Clean(strings.TrimSpace(options.PackageDir)) + if packageDir == "." || packageDir == "" { + packageDir = "mcpgen" + } + if filepath.IsAbs(packageDir) || packageDir == ".." || strings.HasPrefix(packageDir, ".."+string(filepath.Separator)) { + return normalizedOptions{}, fmt.Errorf("package dir %q must be relative to the project", options.PackageDir) + } + + packageName := strings.TrimSpace(options.PackageName) + if packageName == "" { + packageName = filepath.Base(packageDir) + } + if !token.IsIdentifier(packageName) { + return normalizedOptions{}, fmt.Errorf("package name %q is not a valid Go identifier", packageName) + } + + return normalizedOptions{ + ProjectDir: resolvedProjectDir, + ManifestPath: resolvedManifest, + PackageDir: packageDir, + PackageName: packageName, + Check: options.Check, + }, nil +} + +func renderManifestLoader(packageName, manifestContent string) (string, error) { + source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. + +package %s + +import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" + +const embeddedManifest = %s + +func LoadManifest(startDir string) (fwmanifest.File, string, error) { + return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest) +} +`, packageName, strconv.Quote(manifestContent)) + + formatted, err := format.Source([]byte(source)) + if err != nil { + return "", fmt.Errorf("format generated manifest loader: %w", err) + } + + return string(formatted), nil +} + +func writeGeneratedFile(path, content string, mode os.FileMode) error { + current, err := os.ReadFile(path) + if err == nil && bytes.Equal(current, []byte(content)) { + return nil + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read generated file %q: %w", path, err) + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create generated directory %q: %w", dir, err) + } + + if mode == 0 { + mode = 0o644 + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + return fmt.Errorf("write generated file %q: %w", path, err) + } + + return nil +} diff --git a/generate/generate_test.go b/generate/generate_test.go new file mode 100644 index 0000000..f75718e --- /dev/null +++ b/generate/generate_test.go @@ -0,0 +1,213 @@ +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, []string{filepath.Join("mcpgen", "manifest.go")}) { + t.Fatalf("result files = %v", result.Files) + } + + generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go") + content, err := os.ReadFile(generatedPath) + if err != nil { + t.Fatalf("ReadFile generated manifest: %v", err) + } + + for _, snippet := range []string{ + "// Code generated by mcp-framework generate. DO NOT EDIT.", + "package mcpgen", + "import fwmanifest \"gitea.lclr.dev/AI/mcp-framework/manifest\"", + "const embeddedManifest = ", + "func LoadManifest(startDir string) (fwmanifest.File, string, error) {", + "return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)", + `binary_name = \"demo-mcp\"`, + } { + if !strings.Contains(string(content), snippet) { + t.Fatalf("generated manifest.go missing snippet %q:\n%s", snippet, content) + } + } +} + +func 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, []string{filepath.Join("internal", "generated", "manifest.go")}) { + 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" +`) + 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", "./...") + cmd.Dir = projectDir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go test generated project: %v\n%s", err, output) + } +} + +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 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/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n" + if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil { + t.Fatalf("WriteFile go.mod: %v", err) + } + + goSum, err := os.ReadFile(filepath.Join(repoRoot, "go.sum")) + if err != nil { + t.Fatalf("ReadFile go.sum: %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "go.sum"), goSum, 0o600); err != nil { + t.Fatalf("WriteFile go.sum: %v", err) + } + + testFile := `package main + +import ( + "testing" + + "example.com/generated-demo/mcpgen" + fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +func TestGeneratedLoaderUsesEmbeddedManifest(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) + } +} +` + if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil { + t.Fatalf("WriteFile main_test.go: %v", err) + } +} -- 2.45.2 From a79f73825f149926d0197764cccba2bebad2c2d4 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 11:57:44 +0200 Subject: [PATCH 46/58] feat: generate manifest helper glue --- docs/generate.md | 57 ++++++++- generate/generate.go | 237 +++++++++++++++++++++++++++++++++++++- generate/generate_test.go | 157 ++++++++++++++++++++++++- 3 files changed, 439 insertions(+), 12 deletions(-) diff --git a/docs/generate.md b/docs/generate.md index 6f9ea58..ebf0d03 100644 --- a/docs/generate.md +++ b/docs/generate.md @@ -1,8 +1,7 @@ # 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. Le premier usage couvert est le loader de -manifeste embarqué. +racine d'un projet existant. ## Usage @@ -18,9 +17,12 @@ génère : ```text mcpgen/ manifest.go + metadata.go + update.go + secretstore.go ``` -Le fichier généré expose : +Le package généré expose le loader de manifeste : ```go func LoadManifest(startDir string) (manifest.File, string, error) @@ -30,6 +32,48 @@ 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é. + ## Flags - `--manifest` : chemin du `mcp.toml` à lire. Par défaut, `./mcp.toml`. @@ -53,7 +97,12 @@ Pour remplacer un wrapper local du type `internal/manifest` : `example.com/my-mcp/mcpgen`. 4. Remplacer les appels `manifest.Load(...)` du wrapper par `mcpgen.LoadManifest(...)`. -5. Supprimer l'ancien wrapper manuel. +5. Remplacer les reconstructions locales d'options update par + `mcpgen.UpdateOptions(...)` ou `mcpgen.RunUpdate(...)`. +6. Remplacer les wrappers secret store qui ne font que brancher le loader par + `mcpgen.OpenSecretStore`, `mcpgen.DescribeSecretRuntime` et + `mcpgen.PreflightSecretStore`. +7. Supprimer l'ancien wrapper manuel. Après génération, un simple `go build ./...` suffit. La compilation ne dépend pas de la commande `mcp-framework`. diff --git a/generate/generate.go b/generate/generate.go index 7d4a497..711cedf 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -36,7 +36,8 @@ func Generate(options Options) (Result, error) { return Result{}, err } - if _, err := manifest.Load(normalized.ManifestPath); err != nil { + manifestFile, err := manifest.Load(normalized.ManifestPath) + if err != nil { return Result{}, err } @@ -45,7 +46,19 @@ func Generate(options Options) (Result, error) { return Result{}, fmt.Errorf("read manifest %s: %w", normalized.ManifestPath, err) } - content, err := renderManifestLoader(normalized.PackageName, string(manifestContent)) + 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 } @@ -53,7 +66,22 @@ func Generate(options Options) (Result, error) { files := []generatedFile{ { Path: filepath.Join(normalized.PackageDir, "manifest.go"), - Content: content, + 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, }, } @@ -189,6 +217,209 @@ func LoadManifest(startDir string) (fwmanifest.File, string, error) { return string(formatted), nil } +func renderMetadata(packageName string, manifestFile manifest.File) (string, error) { + bootstrapInfo := manifestFile.BootstrapInfo() + + source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. + +package %s + +import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" + +const BinaryName = %s +const DefaultDescription = %s +const DocsURL = %s + +func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) { + manifestFile, source, err := LoadManifest(startDir) + if err != nil { + return fwmanifest.BootstrapMetadata{}, "", err + } + + return manifestFile.BootstrapInfo(), source, nil +} + +func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) { + manifestFile, source, err := LoadManifest(startDir) + if err != nil { + return fwmanifest.ScaffoldMetadata{}, "", err + } + + return manifestFile.ScaffoldInfo(), source, nil +} +`, packageName, strconv.Quote(manifestFile.BinaryName), strconv.Quote(bootstrapInfo.Description), strconv.Quote(manifestFile.DocsURL)) + + return formatGenerated("metadata", source) +} + +func renderUpdate(packageName string) (string, error) { + source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. + +package %s + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + + fwupdate "gitea.lclr.dev/AI/mcp-framework/update" +) + +func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) { + return UpdateOptionsFrom(".", version, stdout) +} + +func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) { + manifestFile, _, err := LoadManifest(startDir) + if err != nil { + return fwupdate.Options{}, err + } + + binaryName := strings.TrimSpace(manifestFile.BinaryName) + if binaryName == "" { + binaryName = BinaryName + } + + return fwupdate.Options{ + CurrentVersion: version, + Stdout: stdout, + BinaryName: binaryName, + ReleaseSource: manifestFile.Update.ReleaseSource(), + }, nil +} + +func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error { + return RunUpdateFrom(ctx, args, ".", version, stdout) +} + +func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error { + fs := flag.NewFlagSet("update", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("update does not accept positional arguments: %%s", strings.Join(fs.Args(), ", ")) + } + + options, err := UpdateOptionsFrom(startDir, version, stdout) + if err != nil { + return err + } + + return fwupdate.Run(ctx, options) +} +`, packageName) + + return formatGenerated("update", source) +} + +func renderSecretStore(packageName string) (string, error) { + source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. + +package %s + +import ( + "os" + "path/filepath" + "strings" + + fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type SecretStoreOptions struct { + ServiceName string + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + BitwardenDebug bool + 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, + 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, + 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 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)) { diff --git a/generate/generate_test.go b/generate/generate_test.go index f75718e..0375689 100644 --- a/generate/generate_test.go +++ b/generate/generate_test.go @@ -24,7 +24,7 @@ description = "Demo MCP" t.Fatalf("Generate returned error: %v", err) } - if !slices.Equal(result.Files, []string{filepath.Join("mcpgen", "manifest.go")}) { + if !slices.Equal(result.Files, defaultGeneratedFiles("mcpgen")) { t.Fatalf("result files = %v", result.Files) } @@ -49,6 +49,87 @@ description = "Demo MCP" } } +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 TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) { projectDir := newProject(t, `binary_name = "demo-mcp"`) @@ -106,7 +187,7 @@ func TestGenerateSupportsManifestAndPackageFlags(t *testing.T) { t.Fatalf("Generate returned error: %v", err) } - if !slices.Equal(result.Files, []string{filepath.Join("internal", "generated", "manifest.go")}) { + if !slices.Equal(result.Files, defaultGeneratedFiles(filepath.Join("internal", "generated"))) { t.Fatalf("result files = %v", result.Files) } @@ -135,6 +216,17 @@ 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" `) writeModule(t, projectDir) @@ -146,7 +238,7 @@ docs_url = "https://docs.example.com/embedded" t.Fatalf("Remove runtime manifest: %v", err) } - cmd := exec.Command("go", "test", "./...") + cmd := exec.Command("go", "test", "-mod=mod", "./...") cmd.Dir = projectDir output, err := cmd.CombinedOutput() if err != nil { @@ -164,6 +256,15 @@ func newProject(t *testing.T, manifest string) string { 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 writeModule(t *testing.T, projectDir string) { t.Helper() @@ -172,7 +273,7 @@ func writeModule(t *testing.T, projectDir string) { t.Fatalf("Abs repo root: %v", err) } - goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n" + goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n" if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil { t.Fatalf("WriteFile go.mod: %v", err) } @@ -188,13 +289,15 @@ func writeModule(t *testing.T, projectDir string) { testFile := `package main import ( + "io" "testing" + fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" "example.com/generated-demo/mcpgen" fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" ) -func TestGeneratedLoaderUsesEmbeddedManifest(t *testing.T) { +func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) { file, source, err := mcpgen.LoadManifest(".") if err != nil { t.Fatalf("LoadManifest returned error: %v", err) @@ -205,6 +308,50 @@ func TestGeneratedLoaderUsesEmbeddedManifest(t *testing.T) { 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)) + } } ` if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil { -- 2.45.2 From 17b1b9968652980abbdd3ce6d852a7cdb179bcfa Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 12:02:23 +0200 Subject: [PATCH 47/58] feat: generate config field helpers --- docs/generate.md | 19 +++++ docs/manifest.md | 35 ++++++++ generate/generate.go | 169 ++++++++++++++++++++++++++++++++++++++ generate/generate_test.go | 112 +++++++++++++++++++++++++ manifest/manifest.go | 37 +++++++++ manifest/manifest_test.go | 73 ++++++++++++++++ 6 files changed, 445 insertions(+) diff --git a/docs/generate.md b/docs/generate.md index ebf0d03..7f82371 100644 --- a/docs/generate.md +++ b/docs/generate.md @@ -20,6 +20,7 @@ mcpgen/ metadata.go update.go secretstore.go + config.go # si [[config.fields]] existe ``` Le package généré expose le loader de manifeste : @@ -74,6 +75,24 @@ func PreflightSecretStore(options SecretStoreOptions) (secretstore.PreflightRepo `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`. diff --git a/docs/manifest.md b/docs/manifest.md index 5868c40..0f945c5 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -36,6 +36,26 @@ 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 : @@ -63,6 +83,21 @@ Champs supportés : - `[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. diff --git a/generate/generate.go b/generate/generate.go index 711cedf..e5d3669 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -62,6 +62,10 @@ func Generate(options Options) (Result, error) { if err != nil { return Result{}, err } + config, err := renderConfig(normalized.PackageName, manifestFile.Config.Fields) + if err != nil { + return Result{}, err + } files := []generatedFile{ { @@ -85,6 +89,13 @@ func Generate(options Options) (Result, error) { 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 { @@ -411,6 +422,164 @@ func secretStoreServiceName(options SecretStoreOptions) string { return formatGenerated("secretstore", source) } +func renderConfig(packageName string, fields []manifest.ConfigField) (string, error) { + if len(fields) == 0 { + return "", nil + } + + var flagsBuilder strings.Builder + var specsBuilder strings.Builder + var setupBuilder strings.Builder + for _, field := range fields { + name := strings.TrimSpace(field.Name) + if name == "" { + return "", fmt.Errorf("generate config field: name must not be empty") + } + + flagName := strings.TrimSpace(field.Flag) + if flagName != "" { + fmt.Fprintf( + &flagsBuilder, + "\tflags.values[%s] = fs.String(%s, \"\", %s)\n", + strconv.Quote(name), + strconv.Quote(flagName), + strconv.Quote(configFieldLabel(field)), + ) + } + + fmt.Fprintf( + &specsBuilder, + "\t\t{Name: %s, Required: %t, DefaultValue: %s, Sources: []fwcli.ValueSource{%s}, FlagKey: %s, EnvKey: %s, ConfigKey: %s, SecretKey: replaceProfile(%s, profile)},\n", + strconv.Quote(name), + field.Required, + strconv.Quote(field.Default), + configSourceList(field.Sources), + strconv.Quote(flagName), + strconv.Quote(field.Env), + strconv.Quote(field.ConfigKey), + strconv.Quote(field.SecretKeyTemplate), + ) + + fmt.Fprintf( + &setupBuilder, + "\t\t{Name: %s, Label: %s, Type: %s, Required: %t, Default: %s, ExistingSecret: existing[%s]},\n", + strconv.Quote(name), + strconv.Quote(configFieldLabel(field)), + configSetupFieldType(field.Type), + field.Required, + strconv.Quote(field.Default), + strconv.Quote(name), + ) + } + + source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. + +package %s + +import ( + "flag" + "strings" + + fwcli "gitea.lclr.dev/AI/mcp-framework/cli" +) + +type ConfigFlags struct { + values map[string]*string +} + +func AddConfigFlags(fs *flag.FlagSet) ConfigFlags { + if fs == nil { + fs = flag.CommandLine + } + + flags := ConfigFlags{ + values: make(map[string]*string), + } +%s + return flags +} + +func ConfigFlagValues(flags ConfigFlags) map[string]string { + values := make(map[string]string) + for name, value := range flags.values { + if value == nil { + continue + } + if trimmed := strings.TrimSpace(*value); trimmed != "" { + values[name] = trimmed + } + } + return values +} + +func ResolveFieldSpecs(profile string) []fwcli.FieldSpec { + return []fwcli.FieldSpec{ +%s + } +} + +func SetupFields(existing map[string]string) []fwcli.SetupField { + if existing == nil { + existing = map[string]string{} + } + + return []fwcli.SetupField{ +%s + } +} + +func replaceProfile(value, profile string) string { + return strings.ReplaceAll(value, "{profile}", strings.TrimSpace(profile)) +} +`, packageName, flagsBuilder.String(), specsBuilder.String(), setupBuilder.String()) + + return formatGenerated("config", source) +} + +func configFieldLabel(field manifest.ConfigField) string { + if label := strings.TrimSpace(field.Label); label != "" { + return label + } + return strings.TrimSpace(field.Name) +} + +func configSourceList(sources []string) string { + if len(sources) == 0 { + return "" + } + + parts := make([]string, 0, len(sources)) + for _, source := range sources { + switch strings.TrimSpace(source) { + case "flag": + parts = append(parts, "fwcli.SourceFlag") + case "env": + parts = append(parts, "fwcli.SourceEnv") + case "config": + parts = append(parts, "fwcli.SourceConfig") + case "secret": + parts = append(parts, "fwcli.SourceSecret") + } + } + + return strings.Join(parts, ", ") +} + +func configSetupFieldType(fieldType string) string { + switch strings.TrimSpace(fieldType) { + case "url": + return "fwcli.SetupFieldURL" + case "secret": + return "fwcli.SetupFieldSecret" + case "bool": + return "fwcli.SetupFieldBool" + case "list": + return "fwcli.SetupFieldList" + default: + return "fwcli.SetupFieldString" + } +} + func formatGenerated(name, source string) (string, error) { formatted, err := format.Source([]byte(source)) if err != nil { diff --git a/generate/generate_test.go b/generate/generate_test.go index 0375689..1420809 100644 --- a/generate/generate_test.go +++ b/generate/generate_test.go @@ -130,6 +130,62 @@ description = "Demo MCP" } } +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"`) @@ -227,6 +283,26 @@ 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) @@ -265,6 +341,16 @@ func defaultGeneratedFiles(packageDir string) []string { } } +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() @@ -289,9 +375,11 @@ func writeModule(t *testing.T, projectDir string) { testFile := `package main import ( + "flag" "io" "testing" + fwcli "gitea.lclr.dev/AI/mcp-framework/cli" fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" "example.com/generated-demo/mcpgen" fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" @@ -352,6 +440,30 @@ func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) { 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 { diff --git a/manifest/manifest.go b/manifest/manifest.go index b35dcf5..59c1b88 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -23,6 +23,7 @@ type File struct { SecretStore SecretStore `toml:"secret_store"` Profiles Profiles `toml:"profiles"` Bootstrap Bootstrap `toml:"bootstrap"` + Config Config `toml:"config"` } type Update struct { @@ -60,6 +61,23 @@ 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 @@ -180,6 +198,7 @@ func (f *File) normalize() { f.SecretStore.normalize() f.Profiles.normalize() f.Bootstrap.normalize() + f.Config.normalize() } func (u *Update) normalize() { @@ -215,6 +234,24 @@ func (b *Bootstrap) normalize() { b.Description = strings.TrimSpace(b.Description) } +func (c *Config) normalize() { + for i := range c.Fields { + c.Fields[i].normalize() + } +} + +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() diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 46b0ec0..087b006 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -235,6 +235,79 @@ description = " Client MCP interne " } } +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] -- 2.45.2 From afe4c681a19c6755965424956f8118ff735c22db Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 12:07:34 +0200 Subject: [PATCH 48/58] docs: refresh usage documentation --- README.md | 94 ++++++++++++++++++++++++++++------------- docs/README.md | 6 ++- docs/auto-update.md | 2 +- docs/generate.md | 53 ++++++++++++++++------- docs/getting-started.md | 24 +++++++---- docs/limitations.md | 2 +- docs/minimal-example.md | 51 ++++++++++++++++------ docs/packages.md | 1 + docs/scaffolding.md | 2 +- docs/secrets.md | 8 ++-- 10 files changed, 169 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 800642b..e874d95 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,90 @@ # mcp-framework -`mcp-framework` est une bibliothèque Go pour construire des binaires MCP robustes, sans imposer un runtime lourd. +`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. -## Le principal à savoir +## Installation -- Le framework fournit des briques réutilisables : config locale, secrets, résolution CLI, manifeste projet, et auto-update. -- Il peut être utilisé de manière modulaire (package par package) ou avec un bootstrap CLI prêt à l'emploi. -- Il inclut un générateur de squelette (`mcp-framework scaffold init`) pour démarrer un nouveau binaire MCP rapidement. -- Il peut générer la glue Go dérivée d'un manifeste racine (`mcp-framework generate`). -- Toute la documentation détaillée est maintenant organisée dans `docs/` par grandes parties. - -## Démarrage rapide - -Installer le framework dans un projet Go existant : +Dans un projet Go : ```bash go get gitea.lclr.dev/AI/mcp-framework ``` -Initialiser un nouveau projet MCP depuis un dossier vide : +Pour utiliser le CLI : ```bash go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest +``` + +## Créer un projet MCP + +```bash 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 ``` +Le scaffold crée une arborescence prête à adapter : + +```text +cmd//main.go +internal/app/app.go +mcp.toml +install.sh +README.md +``` + +## Générer la glue depuis `mcp.toml` + +Dans un projet qui possède un `mcp.toml` à la racine : + +```bash +mcp-framework generate +``` + +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é. + +En CI : + +```bash +mcp-framework generate --check +``` + +## Utiliser les packages + +Les packages peuvent être utilisés séparément : + +- `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`. + ## Documentation -- Vue d'ensemble : [docs/README.md](docs/README.md) -- Installation et usage type : [docs/getting-started.md](docs/getting-started.md) -- Packages : [docs/packages.md](docs/packages.md) -- Bootstrap CLI : [docs/bootstrap-cli.md](docs/bootstrap-cli.md) -- Manifeste `mcp.toml` : [docs/manifest.md](docs/manifest.md) -- Génération depuis `mcp.toml` : [docs/generate.md](docs/generate.md) -- Scaffolding : [docs/scaffolding.md](docs/scaffolding.md) -- Config JSON : [docs/config.md](docs/config.md) -- Secrets : [docs/secrets.md](docs/secrets.md) -- Helpers CLI : [docs/cli-helpers.md](docs/cli-helpers.md) -- Auto-update : [docs/auto-update.md](docs/auto-update.md) -- Exemple minimal : [docs/minimal-example.md](docs/minimal-example.md) -- Limites actuelles : [docs/limitations.md](docs/limitations.md) +- [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) diff --git a/docs/README.md b/docs/README.md index 4b0360e..05527ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,8 @@ # Documentation mcp-framework -Cette documentation est organisée par grandes parties pour séparer la vue d'ensemble des détails d'implémentation. +`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 @@ -15,4 +17,4 @@ Cette documentation est organisée par grandes parties pour séparer la vue d'en - [Helpers CLI](cli-helpers.md) - [Auto-update](auto-update.md) - [Exemple minimal](minimal-example.md) -- [Limites actuelles](limitations.md) +- [Limites](limitations.md) diff --git a/docs/auto-update.md b/docs/auto-update.md index afce8e1..52ac5ab 100644 --- a/docs/auto-update.md +++ b/docs/auto-update.md @@ -8,7 +8,7 @@ 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 actuellement : +Le format attendu pour la réponse `latest release` est : ```json { diff --git a/docs/generate.md b/docs/generate.md index 7f82371..5defc61 100644 --- a/docs/generate.md +++ b/docs/generate.md @@ -5,7 +5,7 @@ racine d'un projet existant. ## Usage -Depuis la racine du projet consommateur : +Depuis la racine du projet Go : ```bash mcp-framework generate @@ -106,22 +106,45 @@ Exemple CI : mcp-framework generate --check ``` -## Migration d'un wrapper manuel +## Utilisation dans l'application -Pour remplacer un wrapper local du type `internal/manifest` : +Importer le package généré depuis le module de l'application : -1. Déplacer le manifeste projet vers `mcp.toml` à la racine. -2. Lancer `mcp-framework generate`. -3. Remplacer les imports du wrapper local par le package généré, par exemple - `example.com/my-mcp/mcpgen`. -4. Remplacer les appels `manifest.Load(...)` du wrapper par - `mcpgen.LoadManifest(...)`. -5. Remplacer les reconstructions locales d'options update par - `mcpgen.UpdateOptions(...)` ou `mcpgen.RunUpdate(...)`. -6. Remplacer les wrappers secret store qui ne font que brancher le loader par - `mcpgen.OpenSecretStore`, `mcpgen.DescribeSecretRuntime` et - `mcpgen.PreflightSecretStore`. -7. Supprimer l'ancien wrapper manuel. +```go +import "example.com/my-mcp/mcpgen" +``` + +Charger le manifeste : + +```go +file, source, err := mcpgen.LoadManifest(".") +if err != nil { + return err +} +_ = file +_ = source +``` + +Construire les options d'update : + +```go +opts, err := mcpgen.UpdateOptions(version, os.Stdout) +if err != nil { + return err +} +``` + +Ouvrir le secret store configuré par le manifeste : + +```go +store, err := mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{ + LookupEnv: os.LookupEnv, +}) +if err != nil { + return err +} +_ = store +``` Après génération, un simple `go build ./...` suffit. La compilation ne dépend pas de la commande `mcp-framework`. diff --git a/docs/getting-started.md b/docs/getting-started.md index b146f73..d4c5dd0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -29,12 +29,20 @@ go run ./cmd/my-mcp help ## Utilisation type -Le flux typique côté application est : +Un flux complet côté application : -1. Déclarer les sous-commandes communes via `bootstrap` (optionnel). -2. Résoudre le profil actif avec `cli`. -3. Charger la config versionnée avec `config`. -4. Lire les secrets avec `secretstore`. -5. Charger le manifest runtime avec `manifest` (`mcp.toml` local, ou fallback embarqué). -6. Exécuter l'auto-update avec `update` si nécessaire. -7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. +1. Déclarer `mcp.toml` à la racine du module. +2. Lancer `mcp-framework generate` pour produire le package `mcpgen`. +3. Déclarer les sous-commandes communes via `bootstrap` si l'application utilise le bootstrap CLI. +4. Résoudre le profil actif avec `cli`. +5. Charger la config versionnée avec `config`. +6. Lire les secrets avec `secretstore` ou `mcpgen.OpenSecretStore`. +7. Charger le manifest runtime avec `mcpgen.LoadManifest`. +8. Exécuter l'auto-update avec `mcpgen.RunUpdate` ou `update.Run`. +9. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. + +Pour vérifier que le code généré est synchronisé avec le manifeste : + +```bash +mcp-framework generate --check +``` diff --git a/docs/limitations.md b/docs/limitations.md index 773309a..915e894 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -1,3 +1,3 @@ -# Limites actuelles +# Limites - l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes diff --git a/docs/minimal-example.md b/docs/minimal-example.md index 2cf682d..6c4c9f5 100644 --- a/docs/minimal-example.md +++ b/docs/minimal-example.md @@ -1,38 +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" + "gitea.lclr.dev/AI/mcp-framework/config" + "gitea.lclr.dev/AI/mcp-framework/update" +) + +var version = "dev" + type Profile struct { BaseURL string `json:"base_url"` } -var embeddedManifest = `...` // fallback utilisé si aucun mcp.toml runtime n'est trouvé +func main() { + if err := run(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} -func run(ctx context.Context, flagProfile string) error { - cfgStore := config.NewStore[Profile]("my-mcp") +func run(ctx context.Context) error { + cfgStore := config.NewStore[Profile](mcpgen.BinaryName) 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.LoadDefaultOrEmbedded(".", embeddedManifest) + _, source, err := mcpgen.LoadManifest(".") if err != nil { return err } + fmt.Println("manifest:", source) - err = update.Run(ctx, update.Options{ - CurrentVersion: version, - BinaryName: "my-mcp", - ReleaseSource: manifestFile.Update.ReleaseSource(), - }) + updateOptions, err := mcpgen.UpdateOptions(version, os.Stdout) if err != nil { return err } + if err := update.Run(ctx, updateOptions); err != nil { + return err + } - _ = profile + _ = cfg return nil } ``` diff --git a/docs/packages.md b/docs/packages.md index beec9cb..83705cd 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -4,6 +4,7 @@ - `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`. - `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`. - `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. +- `generate` : génération de code Go depuis `mcp.toml` (`mcpgen/manifest.go`, metadata, update, secret store, config fields). - `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage). - `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helpers runtime `OpenFromManifest`, `DescribeRuntime`, `PreflightFromManifest` et formatage homogène via `FormatBackendStatus`. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. diff --git a/docs/scaffolding.md b/docs/scaffolding.md index ce3763f..ee20762 100644 --- a/docs/scaffolding.md +++ b/docs/scaffolding.md @@ -3,7 +3,7 @@ Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : - arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) -- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP) +- 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 diff --git a/docs/secrets.md b/docs/secrets.md index 07d527e..b3f97a7 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -56,7 +56,7 @@ Pour imposer KWallet sur Linux : ```go store, err := secretstore.Open(secretstore.Options{ - ServiceName: "email-mcp", + ServiceName: "my-mcp", BackendPolicy: secretstore.BackendKWalletOnly, }) ``` @@ -65,7 +65,7 @@ Pour imposer Bitwarden via son CLI : ```go store, err := secretstore.Open(secretstore.Options{ - ServiceName: "email-mcp", + ServiceName: "my-mcp", BackendPolicy: secretstore.BackendBitwardenCLI, // Optionnel si `bw` n'est pas dans le PATH : // BitwardenCommand: "/usr/local/bin/bw", @@ -96,7 +96,7 @@ et le persister localement (fichier `0600` sous le répertoire de config utilisa ```go session, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ - ServiceName: "email-mcp", + ServiceName: "my-mcp", Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, @@ -111,7 +111,7 @@ Pour réinjecter automatiquement une session persistée dans l'environnement cou ```go loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ - ServiceName: "email-mcp", + ServiceName: "my-mcp", }) if err != nil { return err -- 2.45.2 From e99a1c109ad30be9a91633807abaee567dff7bca Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 14:46:19 +0200 Subject: [PATCH 49/58] docs: design bitwarden cache --- .../2026-05-02-bitwarden-cache-design.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md diff --git a/docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md b/docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md new file mode 100644 index 0000000..2459467 --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md @@ -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 /` +- `bw get item ` for each candidate item + +These calls can take several seconds when commands such as MCP `setup`, `doctor`, or generated config resolution read multiple secrets or are run repeatedly. + +The framework already requires an unlocked Bitwarden session for this backend and restores a persisted `BW_SESSION` into the process environment when available. The cache must use that runtime session as the trust root without embedding a static key in the binary or repository. + +## Goals + +- Avoid repeated Bitwarden CLI calls for short-lived and long-running framework processes. +- Keep the feature portable across Windows, macOS, Linux, and WSL. +- Enable the encrypted disk cache by default when `BW_SESSION` is available. +- Allow projects and operators to disable the cache. +- Never make cached secrets decryptable with only the installed binary, cache files, and repository content. + +## Non-Goals + +- Protect against an attacker who can read the process memory or environment while the process is running. +- Add an OS keyring dependency for the first implementation. +- Cache non-Bitwarden secret backends. +- Persist decrypted secrets on disk. + +## Configuration + +`manifest.SecretStore` gains: + +```toml +[secret_store] +backend_policy = "bitwarden-cli" +bitwarden_cache = true +``` + +`bitwarden_cache` defaults to `true` when omitted. + +The generated helper options continue to flow through `OpenFromManifest` and `DescribeRuntime`. The runtime option is represented in `secretstore.Options` and `secretstore.OpenFromManifestOptions` so generated packages can still override it programmatically if needed. + +An environment variable can force-disable the cache without editing `mcp.toml`: + +```text +MCP_FRAMEWORK_BITWARDEN_CACHE=0 +``` + +Accepted false values are `0`, `false`, `no`, `off`, and `disabled`, case-insensitive. Any other value leaves the manifest/default behavior in place. + +## Architecture + +The cache is internal to the `bitwarden-cli` backend. + +`bitwardenStore.GetSecret(name)` resolves secrets in this order: + +1. In-memory cache. +2. Encrypted disk cache, only if a cache key can be derived from the effective `BW_SESSION`. +3. Bitwarden CLI lookup. + +After a successful CLI lookup, the secret is written to the memory cache and, when available, the encrypted disk cache. + +`SetSecret` and `DeleteSecret` update Bitwarden first. After success, they invalidate the cache entry for the affected secret. `SetSecret` may repopulate the memory and disk cache with the new value after the Bitwarden write succeeds, but it must not expose a value that failed to persist to Bitwarden. + +## Cache Contents + +Memory entries store: + +- secret value +- expiration timestamp + +Disk entries store encrypted JSON: + +```json +{ + "version": 1, + "service_name": "graylog-mcp", + "secret_name": "profile/prod/api-token", + "scoped_name": "graylog-mcp/profile/prod/api-token", + "created_at": "2026-05-02T10:00:00Z", + "expires_at": "2026-05-02T10:10:00Z", + "value": "secret" +} +``` + +The plaintext exists only before encryption and after decryption in memory. + +## Key Derivation + +Disk cache is enabled only when the effective `BW_SESSION` is non-empty. The effective session is the value available after `EnsureBitwardenSessionEnv`, so it can come from either the process environment or the framework-restored session file. + +The cache derives dedicated keys with HKDF-SHA256: + +```text +master_key = HKDF-SHA256( + input key material: BW_SESSION, + salt: "mcp-framework bitwarden cache salt v1", + info: "mcp-framework bitwarden cache v1" +) +``` + +Separate subkeys are derived from `master_key`: + +- encryption key: `info = "mcp-framework bitwarden cache encryption v1"` +- entry ID key: `info = "mcp-framework bitwarden cache entry id v1"` + +`BW_SESSION` is never used directly as an AES key. + +## Disk Encryption + +Disk entries use AES-256-GCM from the Go standard library. + +Each write generates a fresh random nonce with `crypto/rand`. The file format is JSON with metadata needed to decrypt: + +```json +{ + "version": 1, + "algorithm": "AES-256-GCM", + "nonce": "", + "ciphertext": "" +} +``` + +Authenticated additional data includes stable, non-secret cache context: + +```text +mcp-framework bitwarden cache v1 +service= +secret= +scoped=/ +``` + +If decryption fails, the entry is treated as a cache miss. The framework may remove the unusable file as best effort. + +## Entry Identity + +Disk file names do not expose secret names or Bitwarden item refs. The file name is: + +```text +hex(HMAC-SHA256(entry_id_key, cache_context)) + ".json" +``` + +The cache context includes: + +- cache format version +- service name +- raw secret name +- scoped Bitwarden item name +- backend scope marker + +Because the HMAC key is derived from `BW_SESSION`, changing or losing `BW_SESSION` makes existing file names undiscoverable and existing entries undecryptable. + +## TTL and Invalidation + +Default TTL: 10 minutes. + +TTL applies to both memory and disk entries. Expired entries are treated as misses and may be deleted best-effort. + +The first implementation uses a constant default TTL. It keeps room for a future `bitwarden_cache_ttl` option, but does not add that option now to keep scope tight. + +Invalidation rules: + +- `SetSecret` invalidates the affected entry after the Bitwarden write succeeds. +- `DeleteSecret` invalidates the affected entry after the Bitwarden delete succeeds or confirms the item is already absent. +- `BW_SESSION` change implicitly invalidates disk cache through key derivation. +- `bitwarden_cache = false` disables both memory and disk cache for the backend instance. +- `MCP_FRAMEWORK_BITWARDEN_CACHE=0` disables both memory and disk cache. + +## Storage Location and Permissions + +Disk cache path: + +```text +os.UserCacheDir()/serviceName/bitwarden-cache +``` + +If `os.UserCacheDir` fails, disk cache is disabled and secret reads continue through memory cache and Bitwarden CLI. + +The cache directory is created with `0700` and cache files with `0600` where the platform supports Unix-style permissions. Permission setting errors disable disk cache for that operation rather than failing secret resolution, because Bitwarden remains the source of truth. + +## Error Handling + +Cache failures must not make a healthy Bitwarden backend unusable. + +- Memory cache errors are not expected. +- Disk cache read/decrypt/parse errors are treated as misses. +- Disk cache write errors are ignored after optional debug logging. +- Bitwarden CLI errors keep their current behavior and typed error classification. +- Malformed or expired cache entries are never returned. + +## Tests + +Unit tests should cover: + +- repeated `GetSecret` hits memory and avoids a second CLI item read +- reopened store can read from encrypted disk cache without calling `bw get item` +- disk cache file does not contain the secret value or clear secret name +- cache is disabled by `bitwarden_cache = false` +- cache is disabled by `MCP_FRAMEWORK_BITWARDEN_CACHE=0` +- expired entries are missed and refreshed from Bitwarden +- `SetSecret` invalidates or refreshes stale cache data +- `DeleteSecret` removes cached data +- missing `BW_SESSION` disables disk cache +- changed `BW_SESSION` cannot decrypt a previous disk entry +- manifest parsing preserves the default enabled behavior when the field is omitted + +## Documentation + +Update: + +- `docs/manifest.md` for `[secret_store].bitwarden_cache` +- `docs/secrets.md` for cache behavior, TTL, disable controls, and threat model +- generated/scaffolded `mcp.toml` examples only if the option should be visible by default + +The preferred scaffold output should omit `bitwarden_cache` because the default is enabled. Documentation should show it as the explicit disable knob. -- 2.45.2 From e5f2244ad81e4e30b4b0108185815a29088cd683 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 14:55:52 +0200 Subject: [PATCH 50/58] docs: plan bitwarden cache implementation --- .../plans/2026-05-02-bitwarden-cache.md | 1356 +++++++++++++++++ 1 file changed, 1356 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-02-bitwarden-cache.md diff --git a/docs/superpowers/plans/2026-05-02-bitwarden-cache.md b/docs/superpowers/plans/2026-05-02-bitwarden-cache.md new file mode 100644 index 0000000..e352fed --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-bitwarden-cache.md @@ -0,0 +1,1356 @@ +# Bitwarden Cache Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a secure, portable Bitwarden secret cache with memory-first reads and encrypted disk persistence derived from `BW_SESSION`. + +**Architecture:** The cache is internal to `secretstore` and used only by the `bitwarden-cli` backend. `GetSecret` reads memory, encrypted disk, then Bitwarden CLI; successful CLI reads fill both caches. Manifest and generated helper options carry a default-enabled `bitwarden_cache` option, with an environment override for operational disablement. + +**Tech Stack:** Go 1.25 standard library (`crypto/aes`, `crypto/cipher`, `crypto/hkdf`, `crypto/hmac`, `crypto/rand`, `crypto/sha256`, `encoding/json`, `os`, `time`), existing `manifest`, `secretstore`, and `generate` packages. + +--- + +## File Structure + +- Create `secretstore/bitwarden_cache.go`: cache implementation, key derivation, disk encryption, TTL, path resolution, env override parsing. +- Create `secretstore/bitwarden_cache_test.go`: focused cache tests independent from the Bitwarden fake CLI when possible. +- Modify `secretstore/bitwarden.go`: add cache field to `bitwardenStore`, initialize it in `newBitwardenStore`, and use it in `GetSecret`, `SetSecret`, `DeleteSecret`. +- Modify `secretstore/store.go`: add cache options and defaults to `Options`. +- Modify `secretstore/manifest_open.go`: resolve `manifest.SecretStore.BitwardenCache` into `OpenFromManifestOptions` and `Options`. +- Modify `secretstore/runtime.go`: pass cache option through runtime describe/preflight paths. +- Modify `manifest/manifest.go`: parse and normalize `[secret_store].bitwarden_cache` with tri-state semantics so omission means enabled. +- Modify `manifest/manifest_test.go`: test parsing explicit false and omitted default behavior. +- Modify `secretstore/manifest_open_test.go`: test manifest false disables Bitwarden cache. +- Modify `generate/generate.go`: expose `DisableBitwardenCache` in generated `SecretStoreOptions` and pass it to framework options. +- Modify `generate/generate_test.go`: assert generated helper contains the new option. +- Modify `docs/manifest.md` and `docs/secrets.md`: document cache default, disable controls, TTL, and threat model. + +## Manifest Option Shape + +Use a pointer bool in `manifest.SecretStore` so the code can distinguish omitted from explicit false: + +```go +type SecretStore struct { + BackendPolicy string `toml:"backend_policy"` + BitwardenCache *bool `toml:"bitwarden_cache"` +} +``` + +Use a value bool named `DisableBitwardenCache` in runtime option structs. This preserves default-enabled zero-value behavior while still allowing callers to disable the cache programmatically. The manifest resolver applies `bitwarden_cache = false`, then the runtime disable flag and environment override can force disablement. + +--- + +### Task 1: Manifest Parses `bitwarden_cache` + +**Files:** +- Modify: `manifest/manifest.go` +- Test: `manifest/manifest_test.go` + +- [ ] **Step 1: Write the failing manifest test** + +Add this to `manifest/manifest_test.go`: + +```go +func TestLoadParsesSecretStoreBitwardenCache(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultFile) + + const content = ` +[secret_store] +backend_policy = "bitwarden-cli" +bitwarden_cache = false +` + + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + file, err := Load(path) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if file.SecretStore.BitwardenCache == nil { + t.Fatal("bitwarden cache option is nil, want explicit false pointer") + } + if *file.SecretStore.BitwardenCache { + t.Fatal("bitwarden cache option = true, want false") + } +} + +func TestLoadLeavesOmittedBitwardenCacheUnset(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultFile) + + const content = ` +[secret_store] +backend_policy = "bitwarden-cli" +` + + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + file, err := Load(path) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if file.SecretStore.BitwardenCache != nil { + t.Fatalf("bitwarden cache option = %v, want nil when omitted", *file.SecretStore.BitwardenCache) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +go test ./manifest -run 'TestLoadParsesSecretStoreBitwardenCache|TestLoadLeavesOmittedBitwardenCacheUnset' +``` + +Expected: FAIL because `SecretStore.BitwardenCache` does not exist. + +- [ ] **Step 3: Implement manifest field** + +In `manifest/manifest.go`, change `SecretStore` to: + +```go +type SecretStore struct { + BackendPolicy string `toml:"backend_policy"` + BitwardenCache *bool `toml:"bitwarden_cache"` +} +``` + +Keep `normalize` unchanged except for preserving the pointer: + +```go +func (s *SecretStore) normalize() { + s.BackendPolicy = strings.TrimSpace(s.BackendPolicy) +} +``` + +- [ ] **Step 4: Run manifest tests** + +Run: + +```bash +go test ./manifest -run 'TestLoadParsesSecretStoreBitwardenCache|TestLoadLeavesOmittedBitwardenCacheUnset' +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add manifest/manifest.go manifest/manifest_test.go +git commit -m "feat: parse bitwarden cache manifest option" +``` + +--- + +### Task 2: Add Runtime Cache Option Plumbing + +**Files:** +- Modify: `secretstore/store.go` +- Modify: `secretstore/manifest_open.go` +- Modify: `secretstore/runtime.go` +- Test: `secretstore/manifest_open_test.go` + +- [ ] **Step 1: Write failing manifest plumbing test** + +Add this to `secretstore/manifest_open_test.go`: + +```go +func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) { + cacheDisabled := false + resolution, err := resolveManifestPolicy(OpenFromManifestOptions{ + ServiceName: "email-mcp", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{ + BackendPolicy: string(BackendBitwardenCLI), + BitwardenCache: &cacheDisabled, + }, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("resolveManifestPolicy returned error: %v", err) + } + if resolution.BitwardenCache { + t.Fatal("resolution BitwardenCache = true, want false") + } + if resolution.Policy != BackendBitwardenCLI { + t.Fatalf("resolution policy = %q, want %q", resolution.Policy, BackendBitwardenCLI) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +go test ./secretstore -run TestResolveManifestPolicyPreservesBitwardenCacheDisable +``` + +Expected: FAIL because cache option fields are not wired. + +- [ ] **Step 3: Add option fields** + +In `secretstore/store.go`, update `Options`: + +```go +type Options struct { + ServiceName string + BackendPolicy BackendPolicy + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + BitwardenDebug bool + DisableBitwardenCache bool + Shell string +} +``` + +In `secretstore/manifest_open.go`, update `OpenFromManifestOptions`: + +```go +type OpenFromManifestOptions struct { + ServiceName string + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + BitwardenDebug bool + DisableBitwardenCache bool + Shell string + ManifestLoader ManifestLoader + ExecutableResolver ExecutableResolver +} +``` + +Add a field to `manifestPolicyResolution`: + +```go +type manifestPolicyResolution struct { + Policy BackendPolicy + Source string + BitwardenCache bool +} +``` + +Where `resolveManifestPolicy` returns missing-manifest or omitted-field defaults, set `BitwardenCache: true`. When manifest has an explicit pointer, use it: + +```go +bitwardenCache := true +if file.SecretStore.BitwardenCache != nil { + bitwardenCache = *file.SecretStore.BitwardenCache +} +``` + +Pass it in `OpenFromManifest`: + +```go +DisableBitwardenCache: options.DisableBitwardenCache || !manifestPolicy.BitwardenCache, +``` + +This keeps `OpenFromManifestOptions{}` default-enabled when the manifest omits the field, while still respecting both `bitwarden_cache = false` and a caller's explicit disable flag. If a helper is clearer, add: + +```go +func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool { + return runtimeDisabled || !manifestEnabled +} +``` + +Call it as: + +```go +DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, manifestPolicy.BitwardenCache), +``` + +- [ ] **Step 4: Pass through runtime describe options** + +In `secretstore/runtime.go`, add `DisableBitwardenCache bool` to `DescribeRuntimeOptions`, and pass it to `Open`. + +Use: + +```go +DisableBitwardenCache: options.DisableBitwardenCache, +``` + +- [ ] **Step 5: Run plumbing test** + +Run: + +```bash +go test ./secretstore -run TestResolveManifestPolicyPreservesBitwardenCacheDisable +``` + +Expected: PASS. + +- [ ] **Step 6: Commit compile-safe plumbing** + +Run the broader compile target first: + +```bash +go test ./secretstore ./manifest +``` + +Expected: PASS. + +Then run: + +```bash +git add secretstore/store.go secretstore/manifest_open.go secretstore/runtime.go secretstore/manifest_open_test.go +git commit -m "feat: wire bitwarden cache options" +``` + +--- + +### Task 3: Implement Cache Core + +**Files:** +- Create: `secretstore/bitwarden_cache.go` +- Create: `secretstore/bitwarden_cache_test.go` + +- [ ] **Step 1: Write cache core tests** + +Create `secretstore/bitwarden_cache_test.go`: + +```go +package secretstore + +import ( + "bytes" + "os" + "strings" + "testing" + "time" +) + +func TestBitwardenCacheMemoryHit(t *testing.T) { + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: 10 * time.Minute, + Now: func() time.Time { return time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) }, + CacheDir: t.TempDir(), + Enabled: true, + }) + + cache.store("api-token", "email-mcp/api-token", "secret-v1") + got, ok := cache.loadMemory("api-token", "email-mcp/api-token") + if !ok { + t.Fatal("memory cache miss, want hit") + } + if got != "secret-v1" { + t.Fatalf("memory cache value = %q, want secret-v1", got) + } +} + +func TestBitwardenCacheDiskRoundTripIsEncrypted(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: 10 * time.Minute, + Now: func() time.Time { return now }, + CacheDir: dir, + Enabled: true, + }) + + cache.store("api-token", "email-mcp/api-token", "secret-v1") + + reopened := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: 10 * time.Minute, + Now: func() time.Time { return now.Add(time.Minute) }, + CacheDir: dir, + Enabled: true, + }) + got, ok := reopened.loadDisk("api-token", "email-mcp/api-token") + if !ok { + t.Fatal("disk cache miss, want hit") + } + if got != "secret-v1" { + t.Fatalf("disk cache value = %q, want secret-v1", got) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir cache dir: %v", err) + } + if len(entries) != 1 { + t.Fatalf("cache file count = %d, want 1", len(entries)) + } + data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) + if err != nil { + t.Fatalf("ReadFile cache file: %v", err) + } + if bytes.Contains(data, []byte("secret-v1")) { + t.Fatalf("cache file contains plaintext secret: %s", data) + } + if strings.Contains(entries[0].Name(), "api-token") { + t.Fatalf("cache file name exposes secret name: %s", entries[0].Name()) + } +} + +func TestBitwardenCacheRejectsChangedSession(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: 10 * time.Minute, + Now: func() time.Time { return now }, + CacheDir: dir, + Enabled: true, + }) + cache.store("api-token", "email-mcp/api-token", "secret-v1") + + changed := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v2", + TTL: 10 * time.Minute, + Now: func() time.Time { return now.Add(time.Minute) }, + CacheDir: dir, + Enabled: true, + }) + if got, ok := changed.loadDisk("api-token", "email-mcp/api-token"); ok { + t.Fatalf("disk cache hit with changed session = %q, want miss", got) + } +} + +func TestBitwardenCacheExpiresEntries(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: time.Minute, + Now: func() time.Time { return now }, + CacheDir: dir, + Enabled: true, + }) + cache.store("api-token", "email-mcp/api-token", "secret-v1") + + expired := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: time.Minute, + Now: func() time.Time { return now.Add(2 * time.Minute) }, + CacheDir: dir, + Enabled: true, + }) + if got, ok := expired.load("api-token", "email-mcp/api-token"); ok { + t.Fatalf("expired cache hit = %q, want miss", got) + } +} +``` + +If `filepath` is missing, add it to the imports. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +go test ./secretstore -run 'TestBitwardenCache' +``` + +Expected: FAIL because cache types/functions do not exist. + +- [ ] **Step 3: Implement cache core** + +Create `secretstore/bitwarden_cache.go`: + +```go +package secretstore + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + bitwardenCacheEnvName = "MCP_FRAMEWORK_BITWARDEN_CACHE" + defaultBitwardenCacheTTL = 10 * time.Minute + bitwardenCacheFormatVersion = 1 + bitwardenCacheAlgorithm = "AES-256-GCM" + bitwardenCacheDirName = "bitwarden-cache" + bitwardenCacheSalt = "mcp-framework bitwarden cache salt v1" + bitwardenCacheInfo = "mcp-framework bitwarden cache v1" + bitwardenCacheEncryptionInfo = "mcp-framework bitwarden cache encryption v1" + bitwardenCacheEntryIDInfo = "mcp-framework bitwarden cache entry id v1" + bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1" +) + +type bitwardenCacheOptions struct { + ServiceName string + Session string + TTL time.Duration + Now func() time.Time + CacheDir string + Enabled bool +} + +type bitwardenCache struct { + mu sync.Mutex + enabled bool + serviceName string + ttl time.Duration + now func() time.Time + cacheDir string + encryptionKey []byte + entryIDKey []byte + memory map[string]bitwardenCacheMemoryEntry +} + +type bitwardenCacheMemoryEntry struct { + value string + expiresAt time.Time +} + +type bitwardenCachePlaintext struct { + Version int `json:"version"` + ServiceName string `json:"service_name"` + SecretName string `json:"secret_name"` + ScopedName string `json:"scoped_name"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Value string `json:"value"` +} + +type bitwardenCacheEnvelope struct { + Version int `json:"version"` + Algorithm string `json:"algorithm"` + Nonce string `json:"nonce"` + Ciphertext string `json:"ciphertext"` +} + +func newBitwardenCache(options bitwardenCacheOptions) *bitwardenCache { + now := options.Now + if now == nil { + now = time.Now + } + ttl := options.TTL + if ttl <= 0 { + ttl = defaultBitwardenCacheTTL + } + + cache := &bitwardenCache{ + enabled: options.Enabled, + serviceName: strings.TrimSpace(options.ServiceName), + ttl: ttl, + now: now, + cacheDir: strings.TrimSpace(options.CacheDir), + memory: map[string]bitwardenCacheMemoryEntry{}, + } + if !cache.enabled { + return cache + } + + session := strings.TrimSpace(options.Session) + if session == "" { + return cache + } + masterKey, err := hkdf.Key(sha256.New, []byte(session), []byte(bitwardenCacheSalt), bitwardenCacheInfo, 32) + if err != nil { + return cache + } + cache.encryptionKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEncryptionInfo, 32) + if err != nil { + cache.encryptionKey = nil + return cache + } + cache.entryIDKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEntryIDInfo, 32) + if err != nil { + cache.encryptionKey = nil + cache.entryIDKey = nil + return cache + } + return cache +} + +func (c *bitwardenCache) load(secretName, scopedName string) (string, bool) { + if value, ok := c.loadMemory(secretName, scopedName); ok { + return value, true + } + return c.loadDisk(secretName, scopedName) +} + +func (c *bitwardenCache) store(secretName, scopedName, value string) { + if c == nil || !c.enabled { + return + } + c.storeMemory(secretName, scopedName, value) + c.storeDisk(secretName, scopedName, value) +} + +func (c *bitwardenCache) invalidate(secretName, scopedName string) { + if c == nil { + return + } + key := c.memoryKey(secretName, scopedName) + c.mu.Lock() + delete(c.memory, key) + c.mu.Unlock() + if path, ok := c.entryPath(secretName, scopedName); ok { + _ = os.Remove(path) + } +} + +func (c *bitwardenCache) loadMemory(secretName, scopedName string) (string, bool) { + if c == nil || !c.enabled { + return "", false + } + key := c.memoryKey(secretName, scopedName) + now := c.now() + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.memory[key] + if !ok { + return "", false + } + if !entry.expiresAt.After(now) { + delete(c.memory, key) + return "", false + } + return entry.value, true +} + +func (c *bitwardenCache) storeMemory(secretName, scopedName, value string) { + key := c.memoryKey(secretName, scopedName) + c.mu.Lock() + c.memory[key] = bitwardenCacheMemoryEntry{ + value: value, + expiresAt: c.now().Add(c.ttl), + } + c.mu.Unlock() +} + +func (c *bitwardenCache) loadDisk(secretName, scopedName string) (string, bool) { + path, ok := c.entryPath(secretName, scopedName) + if !ok { + return "", false + } + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + var envelope bitwardenCacheEnvelope + if err := json.Unmarshal(data, &envelope); err != nil { + _ = os.Remove(path) + return "", false + } + plaintext, err := c.decryptEnvelope(secretName, scopedName, envelope) + if err != nil { + _ = os.Remove(path) + return "", false + } + if plaintext.Version != bitwardenCacheFormatVersion || + plaintext.ServiceName != c.serviceName || + plaintext.SecretName != strings.TrimSpace(secretName) || + plaintext.ScopedName != strings.TrimSpace(scopedName) || + !plaintext.ExpiresAt.After(c.now()) { + _ = os.Remove(path) + return "", false + } + c.storeMemory(secretName, scopedName, plaintext.Value) + return plaintext.Value, true +} + +func (c *bitwardenCache) storeDisk(secretName, scopedName, value string) { + if c.cacheDir == "" || len(c.encryptionKey) == 0 || len(c.entryIDKey) == 0 { + return + } + path, ok := c.entryPath(secretName, scopedName) + if !ok { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return + } + _ = os.Chmod(filepath.Dir(path), 0o700) + + now := c.now() + plaintext := bitwardenCachePlaintext{ + Version: bitwardenCacheFormatVersion, + ServiceName: c.serviceName, + SecretName: strings.TrimSpace(secretName), + ScopedName: strings.TrimSpace(scopedName), + CreatedAt: now, + ExpiresAt: now.Add(c.ttl), + Value: value, + } + envelope, err := c.encryptPlaintext(secretName, scopedName, plaintext) + if err != nil { + return + } + data, err := json.Marshal(envelope) + if err != nil { + return + } + tmp, err := os.CreateTemp(filepath.Dir(path), "bitwarden-cache-*.tmp") + if err != nil { + return + } + tmpPath := tmp.Name() + cleanup := true + defer func() { + _ = tmp.Close() + if cleanup { + _ = os.Remove(tmpPath) + } + }() + _ = tmp.Chmod(0o600) + if _, err := tmp.Write(data); err != nil { + return + } + if err := tmp.Close(); err != nil { + return + } + if err := os.Rename(tmpPath, path); err != nil { + return + } + _ = os.Chmod(path, 0o600) + cleanup = false +} + +func (c *bitwardenCache) encryptPlaintext(secretName, scopedName string, plaintext bitwardenCachePlaintext) (bitwardenCacheEnvelope, error) { + raw, err := json.Marshal(plaintext) + if err != nil { + return bitwardenCacheEnvelope{}, err + } + block, err := aes.NewCipher(c.encryptionKey) + if err != nil { + return bitwardenCacheEnvelope{}, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return bitwardenCacheEnvelope{}, err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return bitwardenCacheEnvelope{}, err + } + ciphertext := aead.Seal(nil, nonce, raw, c.additionalData(secretName, scopedName)) + return bitwardenCacheEnvelope{ + Version: bitwardenCacheFormatVersion, + Algorithm: bitwardenCacheAlgorithm, + Nonce: base64.StdEncoding.EncodeToString(nonce), + Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), + }, nil +} + +func (c *bitwardenCache) decryptEnvelope(secretName, scopedName string, envelope bitwardenCacheEnvelope) (bitwardenCachePlaintext, error) { + if envelope.Version != bitwardenCacheFormatVersion || envelope.Algorithm != bitwardenCacheAlgorithm { + return bitwardenCachePlaintext{}, errors.New("unsupported bitwarden cache envelope") + } + nonce, err := base64.StdEncoding.DecodeString(envelope.Nonce) + if err != nil { + return bitwardenCachePlaintext{}, err + } + ciphertext, err := base64.StdEncoding.DecodeString(envelope.Ciphertext) + if err != nil { + return bitwardenCachePlaintext{}, err + } + block, err := aes.NewCipher(c.encryptionKey) + if err != nil { + return bitwardenCachePlaintext{}, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return bitwardenCachePlaintext{}, err + } + raw, err := aead.Open(nil, nonce, ciphertext, c.additionalData(secretName, scopedName)) + if err != nil { + return bitwardenCachePlaintext{}, err + } + var plaintext bitwardenCachePlaintext + if err := json.Unmarshal(raw, &plaintext); err != nil { + return bitwardenCachePlaintext{}, err + } + return plaintext, nil +} + +func (c *bitwardenCache) entryPath(secretName, scopedName string) (string, bool) { + if c == nil || !c.enabled || c.cacheDir == "" || len(c.entryIDKey) == 0 { + return "", false + } + mac := hmac.New(sha256.New, c.entryIDKey) + _, _ = mac.Write([]byte(c.cacheContext(secretName, scopedName))) + return filepath.Join(c.cacheDir, hex.EncodeToString(mac.Sum(nil))+".json"), true +} + +func (c *bitwardenCache) memoryKey(secretName, scopedName string) string { + return c.cacheContext(secretName, scopedName) +} + +func (c *bitwardenCache) additionalData(secretName, scopedName string) []byte { + return []byte(fmt.Sprintf( + "mcp-framework bitwarden cache v1\nservice=%s\nsecret=%s\nscoped=%s", + c.serviceName, + strings.TrimSpace(secretName), + strings.TrimSpace(scopedName), + )) +} + +func (c *bitwardenCache) cacheContext(secretName, scopedName string) string { + return fmt.Sprintf( + "version=%d\nservice=%s\nsecret=%s\nscoped=%s\nscope=%s", + bitwardenCacheFormatVersion, + c.serviceName, + strings.TrimSpace(secretName), + strings.TrimSpace(scopedName), + bitwardenCacheContextScope, + ) +} + +func resolveBitwardenCacheDir(serviceName string) string { + cacheRoot, err := os.UserCacheDir() + if err != nil { + return "" + } + return filepath.Join(cacheRoot, strings.TrimSpace(serviceName), bitwardenCacheDirName) +} + +func bitwardenCacheDisabledByEnv() bool { + raw, ok := os.LookupEnv(bitwardenCacheEnvName) + if !ok { + return false + } + switch strings.ToLower(strings.TrimSpace(raw)) { + case "0", "false", "no", "off", "disabled": + return true + default: + return false + } +} +``` + +- [ ] **Step 4: Fix test imports** + +Ensure `secretstore/bitwarden_cache_test.go` imports `path/filepath` because `TestBitwardenCacheDiskRoundTripIsEncrypted` uses it. + +- [ ] **Step 5: Run cache core tests** + +Run: + +```bash +go test ./secretstore -run 'TestBitwardenCache' +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add secretstore/bitwarden_cache.go secretstore/bitwarden_cache_test.go +git commit -m "feat: add encrypted bitwarden cache core" +``` + +--- + +### Task 4: Integrate Cache With Bitwarden Store + +**Files:** +- Modify: `secretstore/bitwarden.go` +- Modify: `secretstore/bitwarden_test.go` +- Modify: `secretstore/manifest_open_test.go` + +- [ ] **Step 1: Add integration tests** + +Add these tests to `secretstore/bitwarden_test.go`: + +```go +func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + for i := 0; i < 2; i++ { + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret #%d returned error: %v", i+1, err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) + } + } + if fakeCLI.getItemCalls != 1 { + t.Fatalf("bw get item count = %d, want 1 with memory cache", fakeCLI.getItemCalls) + } +} + +func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) { + withBitwardenSession(t) + t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0") + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + for i := 0; i < 2; i++ { + if _, err := store.GetSecret("api-token"); err != nil { + t.Fatalf("GetSecret #%d returned error: %v", i+1, err) + } + } + if fakeCLI.getItemCalls != 2 { + t.Fatalf("bw get item count = %d, want 2 when env disables cache", fakeCLI.getItemCalls) + } +} +``` + +Add this test to `secretstore/manifest_open_test.go`: + +```go +func TestOpenFromManifestAppliesBitwardenCacheDisable(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + cacheDisabled := false + store, err := OpenFromManifest(OpenFromManifestOptions{ + ServiceName: "email-mcp", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{ + BackendPolicy: string(BackendBitwardenCLI), + BitwardenCache: &cacheDisabled, + }, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("OpenFromManifest returned error: %v", err) + } + + for i := 0; i < 2; i++ { + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret #%d returned error: %v", i+1, err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) + } + } + if fakeCLI.getItemCalls != 2 { + t.Fatalf("bw get item count = %d, want 2 when manifest disables cache", fakeCLI.getItemCalls) + } +} +``` + +- [ ] **Step 2: Run integration tests to verify failure** + +Run: + +```bash +go test ./secretstore -run 'TestBitwardenStoreGetSecretUsesMemoryCache|TestBitwardenStoreCacheDisabledByEnv|TestOpenFromManifestAppliesBitwardenCacheDisable' +``` + +Expected: FAIL until `bitwardenStore` uses the cache. + +- [ ] **Step 3: Add cache field and initialization** + +In `secretstore/bitwarden.go`, update `bitwardenStore`: + +```go +type bitwardenStore struct { + command string + serviceName string + debug bool + cache *bitwardenCache +} +``` + +In `newBitwardenStore`, after `EnsureBitwardenSessionEnv`, read the effective session: + +```go +session, _ := os.LookupEnv(bitwardenSessionEnvName) +cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv() +``` + +When constructing the store: + +```go +store := &bitwardenStore{ + command: command, + serviceName: serviceName, + debug: debugEnabled, + cache: newBitwardenCache(bitwardenCacheOptions{ + ServiceName: serviceName, + Session: session, + TTL: defaultBitwardenCacheTTL, + CacheDir: resolveBitwardenCacheDir(serviceName), + Enabled: cacheEnabled, + }), +} +``` + +Make sure this happens after session restoration, not before. If needed, move the `store := &bitwardenStore{...}` block below the `EnsureBitwardenSessionEnv` call. + +- [ ] **Step 4: Use cache in `GetSecret`** + +Change `GetSecret`: + +```go +func (s *bitwardenStore) GetSecret(name string) (string, error) { + secretName := s.scopedName(name) + if s.cache != nil { + if secret, ok := s.cache.load(name, secretName); ok { + return secret, nil + } + } + + _, payload, err := s.findItem(secretName, name) + if err != nil { + return "", err + } + + secret, ok := readBitwardenSecret(payload) + if !ok { + return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName) + } + + if s.cache != nil { + s.cache.store(name, secretName, secret) + } + return secret, nil +} +``` + +- [ ] **Step 5: Invalidate on writes and deletes** + +After successful create/edit in `SetSecret`, call: + +```go +if s.cache != nil { + s.cache.invalidate(name, secretName) + s.cache.store(name, secretName, secret) +} +``` + +After successful delete or not-found in `DeleteSecret`, call: + +```go +if s.cache != nil { + s.cache.invalidate(name, secretName) +} +``` + +- [ ] **Step 6: Run integration tests** + +Run: + +```bash +go test ./secretstore -run 'TestBitwardenStoreGetSecretUsesMemoryCache|TestBitwardenStoreCacheDisabledByEnv|TestOpenFromManifestAppliesBitwardenCacheDisable' +``` + +Expected: PASS. + +- [ ] **Step 7: Run all secretstore tests** + +Run: + +```bash +go test ./secretstore +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +Run: + +```bash +git add secretstore/bitwarden.go secretstore/bitwarden_test.go secretstore/manifest_open_test.go +git commit -m "feat: cache bitwarden secret reads" +``` + +--- + +### Task 5: Generated Helper Support + +**Files:** +- Modify: `generate/generate.go` +- Modify: `generate/generate_test.go` + +- [ ] **Step 1: Write failing generated helper assertion** + +In `generate/generate_test.go`, add these expected snippets to the test that checks generated secret store helper content: + +```go +"DisableBitwardenCache bool", +"DisableBitwardenCache: options.DisableBitwardenCache,", +``` + +If there is no focused assertion list, add a test: + +```go +func TestGenerateSecretStoreIncludesBitwardenCacheOption(t *testing.T) { + projectDir := newProject(t, ` +binary_name = "demo-mcp" + +[secret_store] +backend_policy = "bitwarden-cli" +`) + + if _, err := Generate(Options{ProjectDir: projectDir}); err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go")) + if err != nil { + t.Fatalf("ReadFile generated secretstore: %v", err) + } + text := string(content) + for _, snippet := range []string{ + "DisableBitwardenCache bool", + "DisableBitwardenCache: options.DisableBitwardenCache,", + } { + if !strings.Contains(text, snippet) { + t.Fatalf("generated secretstore.go missing %q:\n%s", snippet, text) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +go test ./generate -run TestGenerateSecretStoreIncludesBitwardenCacheOption +``` + +Expected: FAIL because generated code lacks `DisableBitwardenCache`. + +- [ ] **Step 3: Update generator template** + +In `generate/generate.go`, add to generated `SecretStoreOptions`: + +```go +DisableBitwardenCache bool +``` + +Pass to `OpenFromManifestOptions` and `DescribeRuntimeOptions`: + +```go +DisableBitwardenCache: options.DisableBitwardenCache, +``` + +- [ ] **Step 4: Run generator test** + +Run: + +```bash +go test ./generate -run TestGenerateSecretStoreIncludesBitwardenCacheOption +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add generate/generate.go generate/generate_test.go +git commit -m "feat: expose bitwarden cache in generated helpers" +``` + +--- + +### Task 6: Documentation + +**Files:** +- Modify: `docs/manifest.md` +- Modify: `docs/secrets.md` + +- [ ] **Step 1: Update manifest docs** + +In `docs/manifest.md`, update the `[secret_store]` example: + +```toml +[secret_store] +backend_policy = "auto" +# Optionnel: mettre false pour désactiver le cache Bitwarden. +bitwarden_cache = true +``` + +Add field description: + +```markdown +- `[secret_store].bitwarden_cache` : active le cache Bitwarden mémoire + disque chiffré quand `backend_policy = "bitwarden-cli"`. Par défaut, le cache est activé si le champ est absent. Mettre `false` pour le désactiver. +``` + +- [ ] **Step 2: Update secrets docs** + +In `docs/secrets.md`, after the Bitwarden backend section, add this Markdown: + +````markdown +## Cache Bitwarden + +Le backend `bitwarden-cli` met en cache les lectures de secrets par défaut. +Le cache mémoire évite les appels répétés au CLI dans un même process. Le cache +disque est chiffré avec une clé dérivée de `BW_SESSION` via HKDF-SHA256 et AES-GCM. + +TTL par défaut : 10 minutes. + +Pour désactiver le cache dans `mcp.toml` : + +```toml +[secret_store] +backend_policy = "bitwarden-cli" +bitwarden_cache = false +``` + +Pour le désactiver sans modifier le manifeste : + +```bash +MCP_FRAMEWORK_BITWARDEN_CACHE=0 +``` + +Le fichier de cache et le binaire installé ne suffisent pas à déchiffrer les +secrets. Si `BW_SESSION` change ou disparaît, les entrées disque existantes +deviennent inutilisables. Cette protection ne couvre pas un attaquant qui peut +lire l'environnement ou la mémoire du process pendant l'exécution. +```` + +- [ ] **Step 3: Run docs grep** + +Run: + +```bash +rg -n "bitwarden_cache|MCP_FRAMEWORK_BITWARDEN_CACHE|Cache Bitwarden" docs +``` + +Expected: shows entries in `docs/manifest.md` and `docs/secrets.md`. + +- [ ] **Step 4: Commit** + +Run: + +```bash +git add docs/manifest.md docs/secrets.md +git commit -m "docs: document bitwarden cache controls" +``` + +--- + +### Task 7: Final Validation + +**Files:** +- No planned code edits unless validation exposes a defect. + +- [ ] **Step 1: Run targeted package tests** + +Run: + +```bash +go test ./manifest ./secretstore ./generate +``` + +Expected: PASS. + +- [ ] **Step 2: Run full test suite** + +Run: + +```bash +go test ./... +``` + +Expected: PASS. + +- [ ] **Step 3: Inspect git status** + +Run: + +```bash +git status --short +``` + +Expected: clean working tree. + +- [ ] **Step 4: If validation changed files, commit fixes** + +If any fixes were needed: + +```bash +git add +git commit -m "fix: stabilize bitwarden cache" +``` + +If no fixes were needed, do not create an empty commit. + +--- + +## Self-Review Notes + +- Spec coverage: manifest default and disable path are covered by Tasks 1, 2, and 6; secure cache core is covered by Task 3; store integration and invalidation are covered by Task 4; generated helper support is covered by Task 5; docs are covered by Task 6; validation is covered by Task 7. +- Red-flag scan: no incomplete markers or open-ended “add tests” steps remain. +- Type consistency: `BitwardenCache` is the manifest field, `DisableBitwardenCache` is the runtime/generated option, `bitwardenCache` is the internal cache type, and `bitwardenCacheEnvName` is the env constant. -- 2.45.2 From 9675490cd3c231271bb624614faedb88d8c5fe0c Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 14:57:52 +0200 Subject: [PATCH 51/58] feat: parse bitwarden cache manifest option --- manifest/manifest.go | 3 ++- manifest/manifest_test.go | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/manifest/manifest.go b/manifest/manifest.go index 59c1b88..d82acb3 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -49,7 +49,8 @@ type Environment struct { } type SecretStore struct { - BackendPolicy string `toml:"backend_policy"` + BackendPolicy string `toml:"backend_policy"` + BitwardenCache *bool `toml:"bitwarden_cache"` } type Profiles struct { diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 087b006..6b18139 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -235,6 +235,54 @@ description = " Client MCP interne " } } +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) -- 2.45.2 From 1a44a2ea35fbf358085c1bd127456bcdd5e308f7 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 14:59:04 +0200 Subject: [PATCH 52/58] feat: wire bitwarden cache options --- secretstore/manifest_open.go | 65 +++++++++++++++++++------------ secretstore/manifest_open_test.go | 27 +++++++++++++ secretstore/runtime.go | 53 +++++++++++++------------ secretstore/store.go | 17 ++++---- 4 files changed, 104 insertions(+), 58 deletions(-) diff --git a/secretstore/manifest_open.go b/secretstore/manifest_open.go index f8009d4..4b0051b 100644 --- a/secretstore/manifest_open.go +++ b/secretstore/manifest_open.go @@ -15,15 +15,16 @@ 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 - Shell string - ManifestLoader ManifestLoader - ExecutableResolver ExecutableResolver + 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) { @@ -33,14 +34,15 @@ func OpenFromManifest(options OpenFromManifestOptions) (Store, error) { } 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, - Shell: strings.TrimSpace(options.Shell), + 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), }) } @@ -53,8 +55,9 @@ func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolic } type manifestPolicyResolution struct { - Policy BackendPolicy - Source string + Policy BackendPolicy + Source string + BitwardenCache bool } func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) { @@ -82,17 +85,24 @@ func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResol if err != nil { if errors.Is(err, os.ErrNotExist) { return manifestPolicyResolution{ - Policy: BackendAuto, - Source: "", + 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), + Policy: BackendAuto, + Source: strings.TrimSpace(manifestPath), + BitwardenCache: bitwardenCache, }, nil } @@ -106,7 +116,12 @@ func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResol } return manifestPolicyResolution{ - Policy: policy, - Source: strings.TrimSpace(manifestPath), + Policy: policy, + Source: strings.TrimSpace(manifestPath), + BitwardenCache: bitwardenCache, }, nil } + +func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool { + return runtimeDisabled || !manifestEnabled +} diff --git a/secretstore/manifest_open_test.go b/secretstore/manifest_open_test.go index bfe783a..0a92d37 100644 --- a/secretstore/manifest_open_test.go +++ b/secretstore/manifest_open_test.go @@ -111,6 +111,33 @@ func TestOpenFromManifestReturnsExplicitErrorForInvalidManifestPolicy(t *testing } } +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 TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) { execErr := errors.New("boom") _, err := OpenFromManifest(OpenFromManifestOptions{ diff --git a/secretstore/runtime.go b/secretstore/runtime.go index 4d342d7..f0894a9 100644 --- a/secretstore/runtime.go +++ b/secretstore/runtime.go @@ -9,15 +9,16 @@ import ( 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 - Shell string - ManifestLoader ManifestLoader - ExecutableResolver ExecutableResolver + ServiceName string + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + BitwardenDebug bool + DisableBitwardenCache bool + Shell string + ManifestLoader ManifestLoader + ExecutableResolver ExecutableResolver } type RuntimeDescription struct { @@ -47,14 +48,15 @@ type PreflightReport struct { 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, - Shell: options.Shell, - ManifestLoader: options.ManifestLoader, - ExecutableResolver: options.ExecutableResolver, + 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 @@ -68,14 +70,15 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) } 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, - Shell: options.Shell, + 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 diff --git a/secretstore/store.go b/secretstore/store.go index 5d8d2a9..d88d3d2 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -31,14 +31,15 @@ const ( ) type Options struct { - ServiceName string - BackendPolicy BackendPolicy - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string - BitwardenCommand string - BitwardenDebug bool - Shell 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 { -- 2.45.2 From 85da274772dc27d2e973f5561d77243aaa5082d2 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:00:57 +0200 Subject: [PATCH 53/58] feat: add encrypted bitwarden cache core --- secretstore/bitwarden_cache.go | 376 ++++++++++++++++++++++++++++ secretstore/bitwarden_cache_test.go | 131 ++++++++++ 2 files changed, 507 insertions(+) create mode 100644 secretstore/bitwarden_cache.go create mode 100644 secretstore/bitwarden_cache_test.go diff --git a/secretstore/bitwarden_cache.go b/secretstore/bitwarden_cache.go new file mode 100644 index 0000000..a2a9e55 --- /dev/null +++ b/secretstore/bitwarden_cache.go @@ -0,0 +1,376 @@ +package secretstore + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + bitwardenCacheEnvName = "MCP_FRAMEWORK_BITWARDEN_CACHE" + defaultBitwardenCacheTTL = 10 * time.Minute + bitwardenCacheFormatVersion = 1 + bitwardenCacheAlgorithm = "AES-256-GCM" + bitwardenCacheDirName = "bitwarden-cache" + bitwardenCacheSalt = "mcp-framework bitwarden cache salt v1" + bitwardenCacheInfo = "mcp-framework bitwarden cache v1" + bitwardenCacheEncryptionInfo = "mcp-framework bitwarden cache encryption v1" + bitwardenCacheEntryIDInfo = "mcp-framework bitwarden cache entry id v1" + bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1" +) + +type bitwardenCacheOptions struct { + ServiceName string + Session string + TTL time.Duration + Now func() time.Time + CacheDir string + Enabled bool +} + +type bitwardenCache struct { + mu sync.Mutex + enabled bool + serviceName string + ttl time.Duration + now func() time.Time + cacheDir string + encryptionKey []byte + entryIDKey []byte + memory map[string]bitwardenCacheMemoryEntry +} + +type bitwardenCacheMemoryEntry struct { + value string + expiresAt time.Time +} + +type bitwardenCachePlaintext struct { + Version int `json:"version"` + ServiceName string `json:"service_name"` + SecretName string `json:"secret_name"` + ScopedName string `json:"scoped_name"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Value string `json:"value"` +} + +type bitwardenCacheEnvelope struct { + Version int `json:"version"` + Algorithm string `json:"algorithm"` + Nonce string `json:"nonce"` + Ciphertext string `json:"ciphertext"` +} + +func newBitwardenCache(options bitwardenCacheOptions) *bitwardenCache { + now := options.Now + if now == nil { + now = time.Now + } + ttl := options.TTL + if ttl <= 0 { + ttl = defaultBitwardenCacheTTL + } + + cache := &bitwardenCache{ + enabled: options.Enabled, + serviceName: strings.TrimSpace(options.ServiceName), + ttl: ttl, + now: now, + cacheDir: strings.TrimSpace(options.CacheDir), + memory: map[string]bitwardenCacheMemoryEntry{}, + } + if !cache.enabled { + return cache + } + + session := strings.TrimSpace(options.Session) + if session == "" { + return cache + } + masterKey, err := hkdf.Key(sha256.New, []byte(session), []byte(bitwardenCacheSalt), bitwardenCacheInfo, 32) + if err != nil { + return cache + } + cache.encryptionKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEncryptionInfo, 32) + if err != nil { + cache.encryptionKey = nil + return cache + } + cache.entryIDKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEntryIDInfo, 32) + if err != nil { + cache.encryptionKey = nil + cache.entryIDKey = nil + return cache + } + return cache +} + +func (c *bitwardenCache) load(secretName, scopedName string) (string, bool) { + if value, ok := c.loadMemory(secretName, scopedName); ok { + return value, true + } + return c.loadDisk(secretName, scopedName) +} + +func (c *bitwardenCache) store(secretName, scopedName, value string) { + if c == nil || !c.enabled { + return + } + c.storeMemory(secretName, scopedName, value) + c.storeDisk(secretName, scopedName, value) +} + +func (c *bitwardenCache) invalidate(secretName, scopedName string) { + if c == nil { + return + } + key := c.memoryKey(secretName, scopedName) + c.mu.Lock() + delete(c.memory, key) + c.mu.Unlock() + if path, ok := c.entryPath(secretName, scopedName); ok { + _ = os.Remove(path) + } +} + +func (c *bitwardenCache) loadMemory(secretName, scopedName string) (string, bool) { + if c == nil || !c.enabled { + return "", false + } + key := c.memoryKey(secretName, scopedName) + now := c.now() + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.memory[key] + if !ok { + return "", false + } + if !entry.expiresAt.After(now) { + delete(c.memory, key) + return "", false + } + return entry.value, true +} + +func (c *bitwardenCache) storeMemory(secretName, scopedName, value string) { + key := c.memoryKey(secretName, scopedName) + c.mu.Lock() + c.memory[key] = bitwardenCacheMemoryEntry{ + value: value, + expiresAt: c.now().Add(c.ttl), + } + c.mu.Unlock() +} + +func (c *bitwardenCache) loadDisk(secretName, scopedName string) (string, bool) { + path, ok := c.entryPath(secretName, scopedName) + if !ok { + return "", false + } + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + var envelope bitwardenCacheEnvelope + if err := json.Unmarshal(data, &envelope); err != nil { + _ = os.Remove(path) + return "", false + } + plaintext, err := c.decryptEnvelope(secretName, scopedName, envelope) + if err != nil { + _ = os.Remove(path) + return "", false + } + if plaintext.Version != bitwardenCacheFormatVersion || + plaintext.ServiceName != c.serviceName || + plaintext.SecretName != strings.TrimSpace(secretName) || + plaintext.ScopedName != strings.TrimSpace(scopedName) || + !plaintext.ExpiresAt.After(c.now()) { + _ = os.Remove(path) + return "", false + } + c.storeMemory(secretName, scopedName, plaintext.Value) + return plaintext.Value, true +} + +func (c *bitwardenCache) storeDisk(secretName, scopedName, value string) { + if c.cacheDir == "" || len(c.encryptionKey) == 0 || len(c.entryIDKey) == 0 { + return + } + path, ok := c.entryPath(secretName, scopedName) + if !ok { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return + } + _ = os.Chmod(filepath.Dir(path), 0o700) + + now := c.now() + plaintext := bitwardenCachePlaintext{ + Version: bitwardenCacheFormatVersion, + ServiceName: c.serviceName, + SecretName: strings.TrimSpace(secretName), + ScopedName: strings.TrimSpace(scopedName), + CreatedAt: now, + ExpiresAt: now.Add(c.ttl), + Value: value, + } + envelope, err := c.encryptPlaintext(secretName, scopedName, plaintext) + if err != nil { + return + } + data, err := json.Marshal(envelope) + if err != nil { + return + } + tmp, err := os.CreateTemp(filepath.Dir(path), "bitwarden-cache-*.tmp") + if err != nil { + return + } + tmpPath := tmp.Name() + cleanup := true + defer func() { + _ = tmp.Close() + if cleanup { + _ = os.Remove(tmpPath) + } + }() + _ = tmp.Chmod(0o600) + if _, err := tmp.Write(data); err != nil { + return + } + if err := tmp.Close(); err != nil { + return + } + if err := os.Rename(tmpPath, path); err != nil { + return + } + _ = os.Chmod(path, 0o600) + cleanup = false +} + +func (c *bitwardenCache) encryptPlaintext(secretName, scopedName string, plaintext bitwardenCachePlaintext) (bitwardenCacheEnvelope, error) { + raw, err := json.Marshal(plaintext) + if err != nil { + return bitwardenCacheEnvelope{}, err + } + block, err := aes.NewCipher(c.encryptionKey) + if err != nil { + return bitwardenCacheEnvelope{}, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return bitwardenCacheEnvelope{}, err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return bitwardenCacheEnvelope{}, err + } + ciphertext := aead.Seal(nil, nonce, raw, c.additionalData(secretName, scopedName)) + return bitwardenCacheEnvelope{ + Version: bitwardenCacheFormatVersion, + Algorithm: bitwardenCacheAlgorithm, + Nonce: base64.StdEncoding.EncodeToString(nonce), + Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), + }, nil +} + +func (c *bitwardenCache) decryptEnvelope(secretName, scopedName string, envelope bitwardenCacheEnvelope) (bitwardenCachePlaintext, error) { + if envelope.Version != bitwardenCacheFormatVersion || envelope.Algorithm != bitwardenCacheAlgorithm { + return bitwardenCachePlaintext{}, errors.New("unsupported bitwarden cache envelope") + } + nonce, err := base64.StdEncoding.DecodeString(envelope.Nonce) + if err != nil { + return bitwardenCachePlaintext{}, err + } + ciphertext, err := base64.StdEncoding.DecodeString(envelope.Ciphertext) + if err != nil { + return bitwardenCachePlaintext{}, err + } + block, err := aes.NewCipher(c.encryptionKey) + if err != nil { + return bitwardenCachePlaintext{}, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return bitwardenCachePlaintext{}, err + } + raw, err := aead.Open(nil, nonce, ciphertext, c.additionalData(secretName, scopedName)) + if err != nil { + return bitwardenCachePlaintext{}, err + } + var plaintext bitwardenCachePlaintext + if err := json.Unmarshal(raw, &plaintext); err != nil { + return bitwardenCachePlaintext{}, err + } + return plaintext, nil +} + +func (c *bitwardenCache) entryPath(secretName, scopedName string) (string, bool) { + if c == nil || !c.enabled || c.cacheDir == "" || len(c.entryIDKey) == 0 { + return "", false + } + mac := hmac.New(sha256.New, c.entryIDKey) + _, _ = mac.Write([]byte(c.cacheContext(secretName, scopedName))) + return filepath.Join(c.cacheDir, hex.EncodeToString(mac.Sum(nil))+".json"), true +} + +func (c *bitwardenCache) memoryKey(secretName, scopedName string) string { + return c.cacheContext(secretName, scopedName) +} + +func (c *bitwardenCache) additionalData(secretName, scopedName string) []byte { + return []byte(fmt.Sprintf( + "mcp-framework bitwarden cache v1\nservice=%s\nsecret=%s\nscoped=%s", + c.serviceName, + strings.TrimSpace(secretName), + strings.TrimSpace(scopedName), + )) +} + +func (c *bitwardenCache) cacheContext(secretName, scopedName string) string { + return fmt.Sprintf( + "version=%d\nservice=%s\nsecret=%s\nscoped=%s\nscope=%s", + bitwardenCacheFormatVersion, + c.serviceName, + strings.TrimSpace(secretName), + strings.TrimSpace(scopedName), + bitwardenCacheContextScope, + ) +} + +func resolveBitwardenCacheDir(serviceName string) string { + cacheRoot, err := os.UserCacheDir() + if err != nil { + return "" + } + return filepath.Join(cacheRoot, strings.TrimSpace(serviceName), bitwardenCacheDirName) +} + +func bitwardenCacheDisabledByEnv() bool { + raw, ok := os.LookupEnv(bitwardenCacheEnvName) + if !ok { + return false + } + switch strings.ToLower(strings.TrimSpace(raw)) { + case "0", "false", "no", "off", "disabled": + return true + default: + return false + } +} diff --git a/secretstore/bitwarden_cache_test.go b/secretstore/bitwarden_cache_test.go new file mode 100644 index 0000000..5901229 --- /dev/null +++ b/secretstore/bitwarden_cache_test.go @@ -0,0 +1,131 @@ +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) + } +} -- 2.45.2 From fd08615950d2fa2e080763a31b8183ca60f6b3b0 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:03:35 +0200 Subject: [PATCH 54/58] feat: cache bitwarden secret reads --- secretstore/bitwarden.go | 45 +++++++++-- secretstore/bitwarden_test.go | 121 +++++++++++++++++++++++++++++- secretstore/manifest_open_test.go | 45 +++++++++++ 3 files changed, 204 insertions(+), 7 deletions(-) diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 362c019..3de1960 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -50,6 +50,7 @@ type bitwardenStore struct { command string serviceName string debug bool + cache *bitwardenCache } type bitwardenListItem struct { @@ -68,12 +69,6 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string } debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) - store := &bitwardenStore{ - command: command, - serviceName: serviceName, - debug: debugEnabled, - } - if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ ServiceName: serviceName, }); err != nil { @@ -85,6 +80,21 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string ) } + session, _ := os.LookupEnv(bitwardenSessionEnvName) + cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv() + store := &bitwardenStore{ + command: command, + serviceName: serviceName, + debug: debugEnabled, + cache: newBitwardenCache(bitwardenCacheOptions{ + ServiceName: serviceName, + Session: session, + TTL: defaultBitwardenCacheTTL, + CacheDir: resolveBitwardenCacheDir(serviceName), + Enabled: cacheEnabled, + }), + } + if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { if errors.Is(err, exec.ErrNotFound) { return nil, fmt.Errorf( @@ -265,6 +275,10 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { ); 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 @@ -287,11 +301,21 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { 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 + } + } + _, payload, err := s.findItem(secretName, name) if err != nil { return "", err @@ -302,6 +326,9 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) { 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 } @@ -309,6 +336,9 @@ func (s *bitwardenStore) DeleteSecret(name string) error { secretName := s.scopedName(name) 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 { @@ -325,6 +355,9 @@ func (s *bitwardenStore) DeleteSecret(name string) error { return err } + if s.cache != nil { + s.cache.invalidate(name, secretName) + } return nil } diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 4b64c57..1860bc5 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -370,6 +370,124 @@ func TestBitwardenStoreGetSecretReadsSelectedItemOnlyOnce(t *testing.T) { } } +func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + for i := 0; i < 2; i++ { + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret #%d returned error: %v", i+1, err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) + } + } + if fakeCLI.getItemCalls != 1 { + t.Fatalf("bw get item count = %d, want 1 with memory cache", fakeCLI.getItemCalls) + } +} + +func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) { + withBitwardenSession(t) + t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0") + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + for i := 0; i < 2; i++ { + if _, err := store.GetSecret("api-token"); err != nil { + t.Fatalf("GetSecret #%d returned error: %v", i+1, err) + } + } + if fakeCLI.getItemCalls != 2 { + t.Fatalf("bw get item count = %d, want 2 when env disables cache", fakeCLI.getItemCalls) + } +} + +func TestBitwardenStoreSetSecretRefreshesCache(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { + t.Fatalf("SetSecret v1 returned error: %v", err) + } + if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v1" { + t.Fatalf("GetSecret after v1 = %q, %v; want secret-v1, nil", got, err) + } + if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil { + t.Fatalf("SetSecret v2 returned error: %v", err) + } + if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v2" { + t.Fatalf("GetSecret after v2 = %q, %v; want secret-v2, nil", got, err) + } +} + +func TestBitwardenStoreDeleteSecretInvalidatesCache(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + if _, err := store.GetSecret("api-token"); err != nil { + t.Fatalf("GetSecret before delete returned error: %v", err) + } + if err := store.DeleteSecret("api-token"); err != nil { + t.Fatalf("DeleteSecret returned error: %v", err) + } + _, err = store.GetSecret("api-token") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err) + } +} + func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) { store := &bitwardenStore{command: "bw", serviceName: "email-mcp"} withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { @@ -549,7 +667,8 @@ func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { func withBitwardenSession(t *testing.T) { t.Helper() - t.Setenv("BW_SESSION", "test-session") + sessionName := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name()) + t.Setenv("BW_SESSION", "test-session-"+sessionName) } func withBitwardenRunner( diff --git a/secretstore/manifest_open_test.go b/secretstore/manifest_open_test.go index 0a92d37..c60e8c6 100644 --- a/secretstore/manifest_open_test.go +++ b/secretstore/manifest_open_test.go @@ -138,6 +138,51 @@ func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) { } } +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{ -- 2.45.2 From 5552e639748a9ebb3ab9680be2885d9a8ba5f701 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:04:29 +0200 Subject: [PATCH 55/58] feat: expose bitwarden cache in generated helpers --- generate/generate.go | 19 +++++++++++-------- generate/generate_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/generate/generate.go b/generate/generate.go index e5d3669..6128964 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -342,14 +342,15 @@ import ( ) type SecretStoreOptions struct { - ServiceName string - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string - BitwardenCommand string - BitwardenDebug bool - Shell string - ExecutableResolver fwsecretstore.ExecutableResolver + 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) { @@ -372,6 +373,7 @@ func secretStoreOpenOptions(options SecretStoreOptions) fwsecretstore.OpenFromMa KWalletFolder: options.KWalletFolder, BitwardenCommand: options.BitwardenCommand, BitwardenDebug: options.BitwardenDebug, + DisableBitwardenCache: options.DisableBitwardenCache, Shell: options.Shell, ManifestLoader: LoadManifest, ExecutableResolver: options.ExecutableResolver, @@ -386,6 +388,7 @@ func secretStoreDescribeOptions(options SecretStoreOptions) fwsecretstore.Descri KWalletFolder: options.KWalletFolder, BitwardenCommand: options.BitwardenCommand, BitwardenDebug: options.BitwardenDebug, + DisableBitwardenCache: options.DisableBitwardenCache, Shell: options.Shell, ManifestLoader: LoadManifest, ExecutableResolver: options.ExecutableResolver, diff --git a/generate/generate_test.go b/generate/generate_test.go index 1420809..e60de93 100644 --- a/generate/generate_test.go +++ b/generate/generate_test.go @@ -322,6 +322,33 @@ sources = ["flag", "env", "secret"] } } +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() -- 2.45.2 From 0135b093a56c723ef72ac92a2450ccbdd181a09d Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:04:54 +0200 Subject: [PATCH 56/58] docs: document bitwarden cache controls --- docs/manifest.md | 3 +++ docs/secrets.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/docs/manifest.md b/docs/manifest.md index 0f945c5..8311969 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -29,6 +29,8 @@ 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" @@ -80,6 +82,7 @@ Champs supportés : - `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. diff --git a/docs/secrets.md b/docs/secrets.md index b3f97a7..3c011ae 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -72,6 +72,34 @@ store, err := secretstore.Open(secretstore.Options{ }) ``` +## 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 -- 2.45.2 From 893600ffd52173b949e25da810658d46edd92b0d Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:30:18 +0200 Subject: [PATCH 57/58] perf: lazy check bitwarden readiness --- secretstore/bitwarden.go | 63 ++++++++++---- secretstore/bitwarden_cache.go | 4 +- secretstore/bitwarden_cache_test.go | 10 +++ secretstore/bitwarden_test.go | 123 +++++++++++++++++++++++++--- secretstore/runtime.go | 11 +++ 5 files changed, 180 insertions(+), 31 deletions(-) diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 3de1960..9c580b8 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -50,6 +50,8 @@ type bitwardenStore struct { command string serviceName string debug bool + lookupEnv func(string) (string, bool) + shell string cache *bitwardenCache } @@ -86,6 +88,8 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string command: command, serviceName: serviceName, debug: debugEnabled, + lookupEnv: options.LookupEnv, + shell: options.Shell, cache: newBitwardenCache(bitwardenCacheOptions{ ServiceName: serviceName, Session: session, @@ -95,39 +99,41 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string }), } - if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { + 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 nil, fmt.Errorf( - "secret backend policy %q requires bitwarden CLI command %q in PATH: %w", - policy, + return fmt.Errorf( + "requires bitwarden CLI command %q in PATH: %w", command, - ErrBackendUnavailable, + errors.Join(ErrBackendUnavailable, err), ) } - return nil, fmt.Errorf( - "secret backend policy %q cannot verify bitwarden CLI command %q: %w", - policy, + return fmt.Errorf( + "cannot verify bitwarden CLI command %q: %w", command, errors.Join(ErrBackendUnavailable, err), ) } - if err := EnsureBitwardenReady(Options{ - BitwardenCommand: command, - BitwardenDebug: debugEnabled, - LookupEnv: options.LookupEnv, - Shell: options.Shell, - }); err != nil { - return nil, fmt.Errorf( - "secret backend policy %q cannot use bitwarden CLI command %q right now: %w", - policy, + if err := EnsureBitwardenReady(options); err != nil { + return fmt.Errorf( + "cannot use bitwarden CLI command %q right now: %w", command, errors.Join(ErrBackendUnavailable, err), ) } - return store, nil + return nil } func EnsureBitwardenReady(options Options) error { @@ -252,6 +258,10 @@ func detectShellFlavor(shellHint string) string { 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): @@ -316,6 +326,10 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) { } } + 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 @@ -334,6 +348,10 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) { 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 { @@ -365,6 +383,15 @@ func (s *bitwardenStore) scopedName(name string) string { return fmt.Sprintf("%s/%s", s.serviceName, name) } +func (s *bitwardenStore) ensureReady() error { + return verifyBitwardenCLIReady(Options{ + BitwardenCommand: s.command, + BitwardenDebug: s.debug, + LookupEnv: s.lookupEnv, + Shell: s.shell, + }) +} + type bitwardenResolvedItem struct { item bitwardenListItem payload map[string]any diff --git a/secretstore/bitwarden_cache.go b/secretstore/bitwarden_cache.go index a2a9e55..7990e8b 100644 --- a/secretstore/bitwarden_cache.go +++ b/secretstore/bitwarden_cache.go @@ -32,6 +32,8 @@ const ( bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1" ) +var bitwardenUserCacheDir = os.UserCacheDir + type bitwardenCacheOptions struct { ServiceName string Session string @@ -355,7 +357,7 @@ func (c *bitwardenCache) cacheContext(secretName, scopedName string) string { } func resolveBitwardenCacheDir(serviceName string) string { - cacheRoot, err := os.UserCacheDir() + cacheRoot, err := bitwardenUserCacheDir() if err != nil { return "" } diff --git a/secretstore/bitwarden_cache_test.go b/secretstore/bitwarden_cache_test.go index 5901229..d26097d 100644 --- a/secretstore/bitwarden_cache_test.go +++ b/secretstore/bitwarden_cache_test.go @@ -129,3 +129,13 @@ func TestBitwardenCacheExpiresEntries(t *testing.T) { t.Fatalf("expired cache hit = %q, want miss", got) } } + +func withBitwardenUserCacheDir(t *testing.T, resolver func() (string, error)) { + t.Helper() + + previous := bitwardenUserCacheDir + bitwardenUserCacheDir = resolver + t.Cleanup(func() { + bitwardenUserCacheDir = previous + }) +} diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 1860bc5..cafdfc3 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -34,23 +34,29 @@ func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) { if _, ok := store.(*bitwardenStore); !ok { t.Fatalf("store type = %T, want *bitwardenStore", store) } - if !fakeCLI.versionChecked { - t.Fatal("expected bitwarden CLI version check") + if fakeCLI.versionChecked { + t.Fatal("Open should not check bitwarden CLI version before a cache miss or write") } - if !fakeCLI.statusChecked { - t.Fatal("expected bitwarden CLI status check") + if fakeCLI.statusChecked { + t.Fatal("Open should not check bitwarden CLI status before a cache miss or write") } } -func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) { +func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T) { + withBitwardenSession(t) withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { return nil, &exec.Error{Name: command, Err: exec.ErrNotFound} }) - _, err := Open(Options{ + store, err := Open(Options{ ServiceName: "email-mcp", BackendPolicy: BackendBitwardenCLI, }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + _, err = store.GetSecret("api-token") if err == nil { t.Fatal("expected error") } @@ -59,26 +65,28 @@ func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) { } } -func TestOpenBitwardenCLIFailsWhenSessionIsMissing(t *testing.T) { +func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) { fakeCLI := newFakeBitwardenCLI("bw") withBitwardenRunner(t, fakeCLI.run) - _, err := Open(Options{ + store, err := Open(Options{ ServiceName: "email-mcp", BackendPolicy: BackendBitwardenCLI, LookupEnv: func(name string) (string, bool) { return "", false }, }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + _, err = store.GetSecret("api-token") if err == nil { t.Fatal("expected error") } if !errors.Is(err, ErrBWLocked) { t.Fatalf("error = %v, want ErrBWLocked", err) } - if !errors.Is(err, ErrBackendUnavailable) { - t.Fatalf("error = %v, want ErrBackendUnavailable", err) - } } func TestEnsureBitwardenReadyGuidesLoginAndUnlock(t *testing.T) { @@ -404,6 +412,81 @@ func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) { } } +func TestBitwardenStoreDiskCacheHitSkipsBitwardenCLI(t *testing.T) { + withBitwardenSession(t) + cacheRoot := t.TempDir() + withBitwardenUserCacheDir(t, func() (string, error) { + return cacheRoot, nil + }) + + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: os.Getenv("BW_SESSION"), + TTL: defaultBitwardenCacheTTL, + CacheDir: resolveBitwardenCacheDir("email-mcp"), + Enabled: true, + }) + cache.store("api-token", "email-mcp/api-token", "secret-from-cache") + + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + return nil, fmt.Errorf("unexpected bitwarden invocation: %v", args) + }) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "secret-from-cache" { + t.Fatalf("GetSecret = %q, want secret-from-cache", value) + } +} + +func TestBitwardenStoreCacheMissChecksReadinessLazily(t *testing.T) { + withBitwardenSession(t) + withBitwardenUserCacheDir(t, func() (string, error) { + return t.TempDir(), nil + }) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + if fakeCLI.statusChecked { + t.Fatal("Open should not check bitwarden status") + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret = %q, want secret-v1", value) + } + if !fakeCLI.statusChecked { + t.Fatal("cache miss should check bitwarden status before CLI lookup") + } +} + func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) { withBitwardenSession(t) t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0") @@ -522,20 +605,33 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) { func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) { withBitwardenSession(t) + withBitwardenUserCacheDir(t, func() (string, error) { + return t.TempDir(), nil + }) fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } withBitwardenRunner(t, fakeCLI.run) t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1") var logs bytes.Buffer withBitwardenDebugOutput(t, &logs) - _, err := Open(Options{ + store, err := Open(Options{ ServiceName: "email-mcp", BackendPolicy: BackendBitwardenCLI, }) if err != nil { t.Fatalf("Open returned error: %v", err) } + if _, err := store.GetSecret("api-token"); err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } text := logs.String() if !strings.Contains(text, "bw --version") { @@ -669,6 +765,9 @@ func withBitwardenSession(t *testing.T) { t.Helper() sessionName := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name()) t.Setenv("BW_SESSION", "test-session-"+sessionName) + withBitwardenUserCacheDir(t, func() (string, error) { + return t.TempDir(), nil + }) } func withBitwardenRunner( diff --git a/secretstore/runtime.go b/secretstore/runtime.go index f0894a9..7604b99 100644 --- a/secretstore/runtime.go +++ b/secretstore/runtime.go @@ -91,6 +91,17 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) desc.EffectivePolicy = effective desc.DisplayName = BackendDisplayName(effective) } + if 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 } -- 2.45.2 From 1e11181c02554d04cad0e134bb4705d1e27a3954 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:47:07 +0200 Subject: [PATCH 58/58] perf: avoid bitwarden probe in runtime description --- docs/secrets.md | 6 ++++-- secretstore/runtime.go | 4 +++- secretstore/runtime_test.go | 22 ++++++---------------- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/docs/secrets.md b/docs/secrets.md index 3c011ae..724419b 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -187,7 +187,8 @@ effective := secretstore.EffectiveBackendPolicy(store) fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any... ``` -Pour obtenir en un seul appel une description runtime (source manifeste, policy déclarée/effective, disponibilité) : +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{ @@ -202,7 +203,8 @@ fmt.Println(secretstore.FormatBackendStatus(desc)) // declared=... effective=... display=... ready=... source=... ``` -Pour un préflight réutilisable dans `setup`, `config show` et `config test` : +`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{ diff --git a/secretstore/runtime.go b/secretstore/runtime.go index 7604b99..08b93ab 100644 --- a/secretstore/runtime.go +++ b/secretstore/runtime.go @@ -16,6 +16,7 @@ type DescribeRuntimeOptions struct { BitwardenCommand string BitwardenDebug bool DisableBitwardenCache bool + CheckReady bool Shell string ManifestLoader ManifestLoader ExecutableResolver ExecutableResolver @@ -91,7 +92,7 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) desc.EffectivePolicy = effective desc.DisplayName = BackendDisplayName(effective) } - if desc.EffectivePolicy == BackendBitwardenCLI { + if options.CheckReady && desc.EffectivePolicy == BackendBitwardenCLI { if err := verifyBitwardenCLIReady(Options{ BitwardenCommand: options.BitwardenCommand, BitwardenDebug: options.BitwardenDebug, @@ -107,6 +108,7 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) } func PreflightFromManifest(options PreflightOptions) (PreflightReport, error) { + options.CheckReady = true desc, err := DescribeRuntime(options) if err != nil { return PreflightReport{}, err diff --git a/secretstore/runtime_test.go b/secretstore/runtime_test.go index 3daf05b..23f90f1 100644 --- a/secretstore/runtime_test.go +++ b/secretstore/runtime_test.go @@ -46,16 +46,9 @@ func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) { } } -func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(t *testing.T) { +func TestDescribeRuntimeDoesNotProbeBitwardenByDefault(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") - } + return nil, errors.New("unexpected bitwarden invocation") }) desc, err := DescribeRuntime(DescribeRuntimeOptions{ @@ -80,14 +73,11 @@ func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(t *testing.T) { if desc.EffectivePolicy != BackendBitwardenCLI { t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendBitwardenCLI) } - if desc.Ready { - t.Fatalf("Ready = %v, want false", desc.Ready) + if !desc.Ready { + t.Fatalf("Ready = %v, want true without readiness probe", desc.Ready) } - if !errors.Is(desc.ReadyError, ErrBWLocked) { - t.Fatalf("ReadyError = %v, want ErrBWLocked", desc.ReadyError) - } - if !strings.Contains(desc.ReadyError.Error(), "set -x BW_SESSION (bw unlock --raw)") { - t.Fatalf("ReadyError = %v, want fish remediation", desc.ReadyError) + if desc.ReadyError != nil { + t.Fatalf("ReadyError = %v, want nil without readiness probe", desc.ReadyError) } } -- 2.45.2