feat(bootstrap): ajouter DefaultLoginHandler et StandardConfigTestHandler
- DefaultLoginHandler(binaryName) : handler de login Bitwarden prêt à l'emploi avec confirmation. Remplace les réimplémentations identiques dans chaque MCP. - StandardConfigTestHandler(opts) : handler de config test standard sans ManifestCheck. Accepte ConfigCheck, OpenStore, ConnectivityCheck et ExtraChecks. - ManifestCheck dans RunDoctor devient opt-in : inclus uniquement si ManifestDir est fourni (artefact de build, pas de contrainte runtime). - Supprime le handler mort CommandLogin dans bootstrap.Run, désormais remplacé par l'auto-disable et DefaultLoginHandler. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9a52b5dce1
commit
d23d79b6c1
7 changed files with 363 additions and 11 deletions
|
|
@ -8,8 +8,6 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -168,14 +166,6 @@ func Run(ctx context.Context, opts Options) error {
|
||||||
}
|
}
|
||||||
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
|
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
|
||||||
return err
|
return err
|
||||||
case CommandLogin:
|
|
||||||
_, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{
|
|
||||||
ServiceName: normalized.BinaryName,
|
|
||||||
Stdin: normalized.Stdin,
|
|
||||||
Stdout: normalized.Stdout,
|
|
||||||
Stderr: normalized.Stderr,
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
|
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
bootstrap/configtest.go
Normal file
56
bootstrap/configtest.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||||
|
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StandardConfigTestOptions configure le handler de config test standard.
|
||||||
|
// Aucun champ n'est obligatoire — omettez ceux qui ne s'appliquent pas à l'application.
|
||||||
|
type StandardConfigTestOptions struct {
|
||||||
|
// ConfigCheck vérifie que le fichier de configuration est lisible.
|
||||||
|
// Construire avec cli.NewConfigCheck(store).
|
||||||
|
ConfigCheck fwcli.DoctorCheck
|
||||||
|
|
||||||
|
// OpenStore ouvre le secret store pour vérifier sa disponibilité.
|
||||||
|
// Si fourni, un SecretStoreAvailabilityCheck est automatiquement inclus.
|
||||||
|
OpenStore func() (secretstore.Store, error)
|
||||||
|
|
||||||
|
// ConnectivityCheck vérifie la connectivité applicative (IMAP, HTTP, etc.).
|
||||||
|
ConnectivityCheck fwcli.DoctorCheck
|
||||||
|
|
||||||
|
// ExtraChecks contient des vérifications supplémentaires spécifiques à l'application.
|
||||||
|
ExtraChecks []fwcli.DoctorCheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// StandardConfigTestHandler retourne un Handler pour la commande config test.
|
||||||
|
// Il inclut : config (si fourni), secret store (si fourni), connectivité (si fournie),
|
||||||
|
// checks supplémentaires. Le ManifestCheck est intentionnellement absent : le manifest
|
||||||
|
// est un artefact de build, pas une contrainte runtime.
|
||||||
|
func StandardConfigTestHandler(opts StandardConfigTestOptions) Handler {
|
||||||
|
return func(ctx context.Context, inv Invocation) error {
|
||||||
|
doctorOpts := fwcli.DoctorOptions{
|
||||||
|
ConfigCheck: opts.ConfigCheck,
|
||||||
|
ConnectivityCheck: opts.ConnectivityCheck,
|
||||||
|
ExtraChecks: opts.ExtraChecks,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.OpenStore != nil {
|
||||||
|
doctorOpts.SecretStoreCheck = fwcli.SecretStoreAvailabilityCheck(opts.OpenStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
report := fwcli.RunDoctor(ctx, doctorOpts)
|
||||||
|
|
||||||
|
if err := fwcli.RenderDoctorReport(inv.Stdout, report); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.HasFailures() {
|
||||||
|
return fmt.Errorf("config checks failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
155
bootstrap/configtest_test.go
Normal file
155
bootstrap/configtest_test.go
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||||
|
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStandardConfigTestHandlerRendersChecks(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
|
||||||
|
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||||
|
ConfigCheck: func(context.Context) fwcli.DoctorResult {
|
||||||
|
return fwcli.DoctorResult{Name: "config", Status: fwcli.DoctorStatusOK, Summary: "config ok"}
|
||||||
|
},
|
||||||
|
ConnectivityCheck: func(context.Context) fwcli.DoctorResult {
|
||||||
|
return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusOK, Summary: "reachable"}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, "[OK] config") {
|
||||||
|
t.Fatalf("stdout = %q, want [OK] config", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "[OK] connectivity") {
|
||||||
|
t.Fatalf("stdout = %q, want [OK] connectivity", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "manifest") {
|
||||||
|
t.Fatalf("stdout should not contain manifest check, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStandardConfigTestHandlerIncludesSecretStoreCheck(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
|
||||||
|
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||||
|
OpenStore: func() (secretstore.Store, error) {
|
||||||
|
return secretstore.Open(secretstore.Options{
|
||||||
|
BackendPolicy: secretstore.BackendEnvOnly,
|
||||||
|
LookupEnv: func(string) (string, bool) { return "", false },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(stdout.String(), "secret-store") {
|
||||||
|
t.Fatalf("stdout = %q, want secret-store check", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStandardConfigTestHandlerReturnsErrorOnFailure(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
|
||||||
|
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||||
|
ConnectivityCheck: func(context.Context) fwcli.DoctorResult {
|
||||||
|
return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusFail, Summary: "unreachable"}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("handler should return error when checks fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "config checks failed") {
|
||||||
|
t.Fatalf("err = %q, want 'config checks failed'", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "[FAIL] connectivity") {
|
||||||
|
t.Fatalf("stdout = %q, want [FAIL] connectivity", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStandardConfigTestHandlerOmitsManifestCheck(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
|
||||||
|
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||||
|
ExtraChecks: []fwcli.DoctorCheck{
|
||||||
|
func(context.Context) fwcli.DoctorResult {
|
||||||
|
return fwcli.DoctorResult{Name: "custom", Status: fwcli.DoctorStatusOK, Summary: "ok"}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(stdout.String(), "manifest") {
|
||||||
|
t.Fatalf("manifest check should not appear, got:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStandardConfigTestHandlerRunsViaBootstrap(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
openCalled := false
|
||||||
|
|
||||||
|
err := Run(context.Background(), Options{
|
||||||
|
BinaryName: "my-mcp",
|
||||||
|
Args: []string{"config", "test"},
|
||||||
|
Stdout: &stdout,
|
||||||
|
Hooks: Hooks{
|
||||||
|
ConfigTest: StandardConfigTestHandler(StandardConfigTestOptions{
|
||||||
|
OpenStore: func() (secretstore.Store, error) {
|
||||||
|
openCalled = true
|
||||||
|
return secretstore.Open(secretstore.Options{
|
||||||
|
BackendPolicy: secretstore.BackendEnvOnly,
|
||||||
|
LookupEnv: func(string) (string, bool) { return "", false },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run error = %v", err)
|
||||||
|
}
|
||||||
|
if !openCalled {
|
||||||
|
t.Fatal("OpenStore should have been called")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "secret-store") {
|
||||||
|
t.Fatalf("stdout = %q, want secret-store in output", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStandardConfigTestHandlerSecretStoreFailurePropagates(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
storeErr := errors.New("bitwarden unavailable")
|
||||||
|
|
||||||
|
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||||
|
OpenStore: func() (secretstore.Store, error) {
|
||||||
|
return nil, storeErr
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("handler should return error when store fails")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "[FAIL] secret-store") {
|
||||||
|
t.Fatalf("stdout = %q, want [FAIL] secret-store", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
30
bootstrap/login.go
Normal file
30
bootstrap/login.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
var loginBitwarden = secretstore.LoginBitwarden
|
||||||
|
|
||||||
|
// DefaultLoginHandler retourne un Handler qui authentifie et déverrouille
|
||||||
|
// Bitwarden, persiste la session BW_SESSION, et confirme le résultat.
|
||||||
|
// Utiliser comme hook Login lorsqu'aucune logique personnalisée n'est requise.
|
||||||
|
func DefaultLoginHandler(binaryName string) Handler {
|
||||||
|
name := strings.TrimSpace(binaryName)
|
||||||
|
return func(_ context.Context, inv Invocation) error {
|
||||||
|
if _, err := loginBitwarden(secretstore.BitwardenLoginOptions{
|
||||||
|
ServiceName: name,
|
||||||
|
Stdin: inv.Stdin,
|
||||||
|
Stdout: inv.Stdout,
|
||||||
|
Stderr: inv.Stderr,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(inv.Stdout, "Session Bitwarden persistée pour %q.\n", name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
103
bootstrap/login_test.go
Normal file
103
bootstrap/login_test.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withLoginBitwarden(t *testing.T, fn func(secretstore.BitwardenLoginOptions) (string, error)) {
|
||||||
|
t.Helper()
|
||||||
|
previous := loginBitwarden
|
||||||
|
loginBitwarden = fn
|
||||||
|
t.Cleanup(func() { loginBitwarden = previous })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultLoginHandlerPrintsConfirmation(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
|
||||||
|
withLoginBitwarden(t, func(opts secretstore.BitwardenLoginOptions) (string, error) {
|
||||||
|
if opts.ServiceName != "my-mcp" {
|
||||||
|
t.Fatalf("ServiceName = %q, want %q", opts.ServiceName, "my-mcp")
|
||||||
|
}
|
||||||
|
return "session-token", nil
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := DefaultLoginHandler("my-mcp")
|
||||||
|
err := handler(context.Background(), Invocation{
|
||||||
|
Command: CommandLogin,
|
||||||
|
Stdout: &stdout,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, `"my-mcp"`) {
|
||||||
|
t.Fatalf("stdout = %q, want mention of binary name", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "persistée") {
|
||||||
|
t.Fatalf("stdout = %q, want confirmation message", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultLoginHandlerPropagatesError(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
loginErr := errors.New("vault locked")
|
||||||
|
|
||||||
|
withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) {
|
||||||
|
return "", loginErr
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := DefaultLoginHandler("my-mcp")
|
||||||
|
err := handler(context.Background(), Invocation{
|
||||||
|
Command: CommandLogin,
|
||||||
|
Stdout: &stdout,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, loginErr) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, loginErr)
|
||||||
|
}
|
||||||
|
if stdout.Len() > 0 {
|
||||||
|
t.Fatalf("stdout should be empty on error, got %q", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUsesDefaultLoginHandlerWhenHookSet(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
|
||||||
|
withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) {
|
||||||
|
return "tok", nil
|
||||||
|
})
|
||||||
|
|
||||||
|
err := Run(context.Background(), Options{
|
||||||
|
BinaryName: "my-mcp",
|
||||||
|
Args: []string{"login"},
|
||||||
|
Stdout: &stdout,
|
||||||
|
Hooks: Hooks{
|
||||||
|
Login: DefaultLoginHandler("my-mcp"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "persistée") {
|
||||||
|
t.Fatalf("stdout = %q, want confirmation", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunLoginAutoHiddenWithoutHook(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
|
||||||
|
err := Run(context.Background(), Options{
|
||||||
|
BinaryName: "my-mcp",
|
||||||
|
Args: []string{"login"},
|
||||||
|
Stdout: &stdout,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrUnknownCommand) {
|
||||||
|
t.Fatalf("err = %v, want ErrUnknownCommand", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -86,7 +86,7 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
|
||||||
}
|
}
|
||||||
if options.ManifestCheck != nil {
|
if options.ManifestCheck != nil {
|
||||||
checks = append(checks, options.ManifestCheck)
|
checks = append(checks, options.ManifestCheck)
|
||||||
} else {
|
} else if strings.TrimSpace(options.ManifestDir) != "" {
|
||||||
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
|
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
|
||||||
}
|
}
|
||||||
if options.ConnectivityCheck != nil {
|
if options.ConnectivityCheck != nil {
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,24 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunDoctorOmitsManifestCheckWhenDirNotSet(t *testing.T) {
|
||||||
|
report := RunDoctor(context.Background(), DoctorOptions{
|
||||||
|
ConnectivityCheck: func(context.Context) DoctorResult {
|
||||||
|
return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "ok"}
|
||||||
|
},
|
||||||
|
// ManifestDir intentionally empty, ManifestCheck intentionally nil.
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, r := range report.Results {
|
||||||
|
if r.Name == "manifest" {
|
||||||
|
t.Fatalf("manifest check should not be included when ManifestDir is empty, got: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(report.Results) != 1 {
|
||||||
|
t.Fatalf("result count = %d, want 1 (connectivity only)", len(report.Results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) {
|
func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) {
|
||||||
prev := checkBitwardenReady
|
prev := checkBitwardenReady
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue