feat(scaffold): inject install.sh wizard in generated projects
This commit is contained in:
parent
7c239a7e97
commit
f80eebb575
4 changed files with 225 additions and 10 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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 d’environnement connues si besoin.
|
- Ajuster les variables d’environnement connues si besoin.
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue