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) {