feat(scaffold): inject install.sh wizard in generated projects

This commit is contained in:
thibaud-lclr 2026-04-15 10:13:45 +02:00
parent 7c239a7e97
commit f80eebb575
4 changed files with 225 additions and 10 deletions

View file

@ -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`. - `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()`. - `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. - `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`. - `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. - `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 : 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`) - arborescence recommandée (`cmd/<binary>/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` - wiring initial `bootstrap + config + secretstore + update`
- `README.md` de démarrage - `README.md` de démarrage

View file

@ -51,6 +51,9 @@ func TestRunScaffoldInitCreatesProject(t *testing.T) {
if _, err := os.Stat(filepath.Join(target, "mcp.toml")); err != nil { if _, err := os.Stat(filepath.Join(target, "mcp.toml")); err != nil {
t.Fatalf("generated mcp.toml missing: %v", err) 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") { if !strings.Contains(stdout.String(), "Scaffold generated in") {
t.Fatalf("stdout should include generation summary: %q", stdout.String()) t.Fatalf("stdout should include generation summary: %q", stdout.String())

View file

@ -70,18 +70,19 @@ func Generate(options Options) (Result, error) {
} }
files := []generatedFile{ files := []generatedFile{
{Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized)}, {Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized), Mode: 0o644},
{Path: "go.mod", Content: renderTemplate(goModTemplate, normalized)}, {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized), Mode: 0o644},
{Path: "README.md", Content: renderTemplate(readmeTemplate, normalized)}, {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized), Mode: 0o644},
{Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized)}, {Path: "install.sh", Content: renderTemplate(installTemplate, normalized), Mode: 0o755},
{Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized)}, {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized), Mode: 0o644},
{Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized)}, {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)) written := make([]string, 0, len(files))
for _, file := range files { for _, file := range files {
fullPath := filepath.Join(normalized.TargetDir, file.Path) 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 return Result{}, err
} }
written = append(written, file.Path) written = append(written, file.Path)
@ -97,9 +98,10 @@ func Generate(options Options) (Result, error) {
type generatedFile struct { type generatedFile struct {
Path string Path string
Content 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 !overwrite {
if _, err := os.Stat(path); err == nil { if _, err := os.Stat(path); err == nil {
return fmt.Errorf("%w: %s", ErrFileExists, path) 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) 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) return fmt.Errorf("write scaffold file %q: %w", path, err)
} }
@ -326,6 +332,175 @@ const goModTemplate = `module {{.ModulePath}}
go 1.25.0 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 <<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 const mainTemplate = `package main
import ( import (
@ -691,6 +866,7 @@ Binaire MCP généré depuis ` + "`mcp-framework`" + `.
app.go app.go
.gitignore .gitignore
go.mod go.mod
install.sh
mcp.toml mcp.toml
README.md README.md
` + "```" + ` ` + "```" + `
@ -727,9 +903,16 @@ go run ./cmd/{{.BinaryName}} mcp
go run ./cmd/{{.BinaryName}} config test 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 ## Points à adapter
- Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs). - 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`" + `). - Compléter la logique métier dans ` + "`internal/app/app.go`" + ` (` + "`runMCP`" + `).
- Ajuster les variables denvironnement connues si besoin. - Ajuster les variables denvironnement connues si besoin.
` `

View file

@ -35,6 +35,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
"README.md", "README.md",
"cmd/my-mcp/main.go", "cmd/my-mcp/main.go",
"go.mod", "go.mod",
"install.sh",
"internal/app/app.go", "internal/app/app.go",
"mcp.toml", "mcp.toml",
} }
@ -102,12 +103,39 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
for _, snippet := range []string{ for _, snippet := range []string{
"Arborescence générée", "Arborescence générée",
"go run ./cmd/my-mcp setup", "go run ./cmd/my-mcp setup",
"curl -fsSL https://<forge>/<org>/<repo>/raw/branch/main/install.sh | bash",
"internal/app/app.go", "internal/app/app.go",
} { } {
if !strings.Contains(string(readme), snippet) { if !strings.Contains(string(readme), snippet) {
t.Fatalf("README missing snippet %q", 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) { func TestGenerateUsesDefaultsFromTargetDirectory(t *testing.T) {