diff --git a/README.md b/README.md index f9145a8..243a2e5 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ go run ./cmd/my-mcp help - `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`. - `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`. - `manifest` : lecture de `mcp.toml` à la racine du projet, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. -- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, wiring de base et README de démarrage). +- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage). - `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. @@ -191,6 +191,7 @@ _ = scaffoldInfo Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : - arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) +- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard setup/JSON MCP - wiring initial `bootstrap + config + secretstore + update` - `README.md` de démarrage diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go index 6f32c6f..0b17192 100644 --- a/cmd/mcp-framework/main_test.go +++ b/cmd/mcp-framework/main_test.go @@ -51,6 +51,9 @@ func TestRunScaffoldInitCreatesProject(t *testing.T) { if _, err := os.Stat(filepath.Join(target, "mcp.toml")); err != nil { t.Fatalf("generated mcp.toml missing: %v", err) } + if _, err := os.Stat(filepath.Join(target, "install.sh")); err != nil { + t.Fatalf("generated install.sh missing: %v", err) + } if !strings.Contains(stdout.String(), "Scaffold generated in") { t.Fatalf("stdout should include generation summary: %q", stdout.String()) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index bd2c96e..01eca44 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -70,18 +70,19 @@ func Generate(options Options) (Result, error) { } files := []generatedFile{ - {Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized)}, - {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized)}, - {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized)}, - {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized)}, - {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized)}, - {Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized)}, + {Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized), Mode: 0o644}, + {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized), Mode: 0o644}, + {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized), Mode: 0o644}, + {Path: "install.sh", Content: renderTemplate(installTemplate, normalized), Mode: 0o755}, + {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized), Mode: 0o644}, + {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized), Mode: 0o644}, + {Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized), Mode: 0o644}, } written := make([]string, 0, len(files)) for _, file := range files { fullPath := filepath.Join(normalized.TargetDir, file.Path) - if err := writeFile(fullPath, file.Content, normalized.Overwrite); err != nil { + if err := writeFile(fullPath, file.Content, file.Mode, normalized.Overwrite); err != nil { return Result{}, err } written = append(written, file.Path) @@ -97,9 +98,10 @@ func Generate(options Options) (Result, error) { type generatedFile struct { Path string Content string + Mode os.FileMode } -func writeFile(path, content string, overwrite bool) error { +func writeFile(path, content string, mode os.FileMode, overwrite bool) error { if !overwrite { if _, err := os.Stat(path); err == nil { return fmt.Errorf("%w: %s", ErrFileExists, path) @@ -113,7 +115,11 @@ func writeFile(path, content string, overwrite bool) error { return fmt.Errorf("create scaffold directory %q: %w", dir, err) } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + if mode == 0 { + mode = 0o644 + } + + if err := os.WriteFile(path, []byte(content), mode); err != nil { return fmt.Errorf("write scaffold file %q: %w", path, err) } @@ -326,6 +332,175 @@ const goModTemplate = `module {{.ModulePath}} go 1.25.0 ` +const installTemplate = `#!/usr/bin/env bash +set -euo pipefail + +BINARY_NAME="{{.BinaryName}}" +MODULE_PATH="{{.ModulePath}}" +DEFAULT_PROFILE="{{.DefaultProfile}}" +PROFILE_ENV="{{.ProfileEnv}}" + +prompt() { + local label="$1" + local default_value="$2" + local answer="" + + if [ -n "$default_value" ]; then + printf "%s [%s]: " "$label" "$default_value" + else + printf "%s: " "$label" + fi + + if [ -r /dev/tty ]; then + IFS= read -r answer < /dev/tty || answer="" + else + IFS= read -r answer || answer="" + fi + + if [ -z "$answer" ]; then + printf "%s" "$default_value" + return + fi + + printf "%s" "$answer" +} + +go_bin_dir() { + local gobin + gobin="$(go env GOBIN 2>/dev/null || true)" + if [ -n "$gobin" ]; then + printf "%s\n" "$gobin" + return + fi + + go env GOPATH 2>/dev/null | awk '{print $1 "/bin"}' +} + +resolve_binary_path() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + command -v "$BINARY_NAME" + return + fi + + if command -v go >/dev/null 2>&1; then + local bin_dir + bin_dir="$(go_bin_dir)" + if [ -n "$bin_dir" ] && [ -x "$bin_dir/$BINARY_NAME" ]; then + printf "%s\n" "$bin_dir/$BINARY_NAME" + return + fi + fi + + printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" +} + +install_binary() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + printf "Binaire détecté: %s\n" "$(command -v "$BINARY_NAME")" + return + fi + + if ! command -v go >/dev/null 2>&1; then + printf "Go n'est pas installé. Installe Go ou utilise l'option JSON.\n" >&2 + exit 1 + fi + + printf "Installation du binaire via go install...\n" + go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest" + + local bin_dir + bin_dir="$(go_bin_dir)" + if [ -n "$bin_dir" ]; then + printf "Binaire installé dans %s\n" "$bin_dir" + printf "Ajoute ce dossier à ton PATH si nécessaire.\n" + fi +} + +run_setup_wizard() { + install_binary + + local profile + profile="$(prompt "Profil à configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")" + local binary_path + binary_path="$(resolve_binary_path)" + + printf "Lancement de %s setup...\n\n" "$BINARY_NAME" + if [ -r /dev/tty ] && [ -w /dev/tty ]; then + env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty + else + env "${PROFILE_ENV}=${profile}" "$binary_path" setup + fi +} + +print_mcp_json() { + local profile + profile="$(prompt "Profil à exposer dans la config MCP (${PROFILE_ENV})" "$DEFAULT_PROFILE")" + local default_command + default_command="$(resolve_binary_path)" + local command_path + command_path="$(prompt "Commande du serveur MCP" "$default_command")" + + cat <&2 + ;; + esac + done +} + +main "$@" +` + const mainTemplate = `package main import ( @@ -691,6 +866,7 @@ Binaire MCP généré depuis ` + "`mcp-framework`" + `. │ └── app.go ├── .gitignore ├── go.mod +├── install.sh ├── mcp.toml └── README.md ` + "```" + ` @@ -727,9 +903,16 @@ go run ./cmd/{{.BinaryName}} mcp go run ./cmd/{{.BinaryName}} config test ` + "```" + ` +6. Publier un install wizard consommable via ` + "`curl | bash`" + ` : + +` + "```bash" + ` +curl -fsSL https://///raw/branch/main/install.sh | bash +` + "```" + ` + ## Points à adapter - Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs). +- Adapter l'URL ` + "`curl .../install.sh`" + ` à votre forge/répertoire. - Compléter la logique métier dans ` + "`internal/app/app.go`" + ` (` + "`runMCP`" + `). - Ajuster les variables d’environnement connues si besoin. ` diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index c166a3b..98ef6f9 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -35,6 +35,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "README.md", "cmd/my-mcp/main.go", "go.mod", + "install.sh", "internal/app/app.go", "mcp.toml", } @@ -102,12 +103,39 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "Arborescence générée", "go run ./cmd/my-mcp setup", + "curl -fsSL https://///raw/branch/main/install.sh | bash", "internal/app/app.go", } { if !strings.Contains(string(readme), snippet) { t.Fatalf("README missing snippet %q", snippet) } } + + installScriptPath := filepath.Join(target, "install.sh") + installScript, err := os.ReadFile(installScriptPath) + if err != nil { + t.Fatalf("ReadFile install.sh: %v", err) + } + for _, snippet := range []string{ + "#!/usr/bin/env bash", + `MODULE_PATH="example.com/acme/my-mcp"`, + `go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`, + "config MCP (Codex)", + "config MCP (Claude Desktop)", + `"${PROFILE_ENV}=${profile}"`, + } { + if !strings.Contains(string(installScript), snippet) { + t.Fatalf("install.sh missing snippet %q", snippet) + } + } + + info, err := os.Stat(installScriptPath) + if err != nil { + t.Fatalf("Stat install.sh: %v", err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("install.sh mode = %o, want 755", info.Mode().Perm()) + } } func TestGenerateUsesDefaultsFromTargetDirectory(t *testing.T) {