918 lines
22 KiB
Go
918 lines
22 KiB
Go
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), 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, file.Mode, 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
|
||
Mode os.FileMode
|
||
}
|
||
|
||
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)
|
||
} 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 mode == 0 {
|
||
mode = 0o644
|
||
}
|
||
|
||
if err := os.WriteFile(path, []byte(content), mode); 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 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" >&2
|
||
else
|
||
printf "%s: " "$label" >&2
|
||
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 <<JSON
|
||
{
|
||
"mcpServers": {
|
||
"${BINARY_NAME}": {
|
||
"command": "${command_path}",
|
||
"args": ["mcp"],
|
||
"env": {
|
||
"${PROFILE_ENV}": "${profile}"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
JSON
|
||
}
|
||
|
||
print_header() {
|
||
cat <<TXT
|
||
===========================================================
|
||
Installateur MCP pour ${BINARY_NAME}
|
||
===========================================================
|
||
Choisis une action :
|
||
1) Installer le binaire + lancer le setup
|
||
2) Générer un JSON de config MCP (Codex)
|
||
3) Générer un JSON de config MCP (Claude Desktop)
|
||
4) Générer un JSON de config MCP (autre client)
|
||
5) Quitter
|
||
TXT
|
||
}
|
||
|
||
main() {
|
||
while true; do
|
||
print_header
|
||
local choice
|
||
choice="$(prompt "Choix" "1")"
|
||
printf "\n"
|
||
|
||
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"
|
||
print_mcp_json
|
||
return
|
||
;;
|
||
5)
|
||
printf "Annulé.\n"
|
||
return
|
||
;;
|
||
*)
|
||
printf "Choix invalide: %s\n\n" "$choice" >&2
|
||
;;
|
||
esac
|
||
done
|
||
}
|
||
|
||
main "$@"
|
||
`
|
||
|
||
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
|
||
}
|
||
|
||
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",
|
||
}, 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.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||
ServiceName: r.BinaryName,
|
||
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
|
||
├── install.sh
|
||
├── 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
|
||
` + "```" + `
|
||
|
||
6. Publier un install wizard consommable via ` + "`curl | bash`" + ` :
|
||
|
||
` + "```bash" + `
|
||
curl -fsSL https://<forge>/<org>/<repo>/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.
|
||
`
|