Merge pull request 'feat: add MCP scaffold generator' (#20) from feat/mcp-binary-scaffold into release/v1.3
Reviewed-on: https://gitea.lclr.dev/AI/mcp-framework/pulls/20
This commit is contained in:
commit
1b603f552c
5 changed files with 1270 additions and 0 deletions
48
README.md
48
README.md
|
|
@ -17,12 +17,34 @@ 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.
|
||||
- `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 +181,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/<binary>/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é
|
||||
|
|
|
|||
200
cmd/mcp-framework/main.go
Normal file
200
cmd/mcp-framework/main.go
Normal file
|
|
@ -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 <command> [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 <dir> [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --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
|
||||
}
|
||||
101
cmd/mcp-framework/main_test.go
Normal file
101
cmd/mcp-framework/main_test.go
Normal file
|
|
@ -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 <command>") {
|
||||
t.Fatalf("global help should mention command usage: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "scaffold init") {
|
||||
t.Fatalf("global help should mention scaffold init: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffoldInitCreatesProject(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "demo-mcp")
|
||||
args := []string{
|
||||
"scaffold", "init",
|
||||
"--target", target,
|
||||
"--module", "example.com/demo-mcp",
|
||||
"--binary", "demo-mcp",
|
||||
"--profiles", "dev,prod",
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
if err := run(args, &stdout, &stderr); err != nil {
|
||||
t.Fatalf("run returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(target, "cmd", "demo-mcp", "main.go")); err != nil {
|
||||
t.Fatalf("generated main.go missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "internal", "app", "app.go")); err != nil {
|
||||
t.Fatalf("generated app.go missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "mcp.toml")); err != nil {
|
||||
t.Fatalf("generated mcp.toml missing: %v", err)
|
||||
}
|
||||
|
||||
if !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)
|
||||
}
|
||||
}
|
||||
738
scaffold/scaffold.go
Normal file
738
scaffold/scaffold.go
Normal file
|
|
@ -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.
|
||||
`
|
||||
183
scaffold/scaffold_test.go
Normal file
183
scaffold/scaffold_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue