Добавить установщик, проверку версий и инструкцию деплоя на сервер.

Интерактивная настройка домена и БД, эндпоинты /health и /version,
скрипты install/check для Linux и Windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shop
2026-05-16 17:17:19 +03:00
parent 448cf2a465
commit a3d3721724
17 changed files with 784 additions and 23 deletions
+228
View File
@@ -0,0 +1,228 @@
package setup
import (
"bufio"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
)
type Config struct {
SiteDomain string
CaddyEmail string
HTTPPort string
HTTPSPort string
UseDockerDB bool
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
DBSSLMode string
}
func RunInteractive(root string) (Config, error) {
in := bufio.NewReader(os.Stdin)
fmt.Println("=== Установщик ShopNova ===")
fmt.Println()
cfg := Config{}
cfg.SiteDomain = ask(in, "Домен сайта (например shop.example.com, Enter = localhost)", "localhost")
cfg.CaddyEmail = ask(in, "Email для Let's Encrypt (Caddy)", "admin@localhost")
cfg.HTTPPort = ask(in, "HTTP порт", "80")
cfg.HTTPSPort = ask(in, "HTTPS порт", "443")
useDocker := askYesNo(in, "Использовать PostgreSQL из Docker Compose?", true)
cfg.UseDockerDB = useDocker
if useDocker {
cfg.DBHost = "postgres"
cfg.DBPort = "5432"
cfg.DBSSLMode = "require"
fmt.Println("\n--- База данных (контейнер postgres) ---")
} else {
fmt.Println("\n--- База данных (внешний сервер) ---")
cfg.DBHost = ask(in, "Хост БД", "localhost")
cfg.DBPort = ask(in, "Порт БД", "5432")
cfg.DBSSLMode = ask(in, "SSL mode (disable|require|verify-full)", "require")
}
cfg.DBUser = ask(in, "Пользователь БД", "shop")
cfg.DBPassword = askPassword(in, "Пароль БД")
cfg.DBName = ask(in, "Имя базы данных", "shopdb")
if err := WriteFiles(root, cfg); err != nil {
return cfg, err
}
fmt.Println("\n✓ Созданы файлы: .env, caddy/Caddyfile")
fmt.Println("\nДальше:")
fmt.Println(" docker compose up --build -d")
if !useLocalDomain(cfg.SiteDomain) {
fmt.Printf(" Сайт: https://%s\n", cfg.SiteDomain)
} else {
fmt.Printf(" Сайт: http://localhost:%s\n", cfg.HTTPPort)
}
return cfg, nil
}
func WriteFiles(root string, cfg Config) error {
envPath := filepath.Join(root, ".env")
caddyPath := filepath.Join(root, "caddy", "Caddyfile")
if err := os.WriteFile(envPath, []byte(buildEnv(cfg)), 0o600); err != nil {
return fmt.Errorf("write .env: %w", err)
}
if err := os.MkdirAll(filepath.Dir(caddyPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(caddyPath, []byte(buildCaddyfile(cfg)), 0o644); err != nil {
return fmt.Errorf("write Caddyfile: %w", err)
}
return nil
}
func buildEnv(cfg Config) string {
dbURL := DatabaseURL(cfg)
lines := []string{
"# Сгенерировано установщиком ShopNova",
fmt.Sprintf("SITE_DOMAIN=%s", cfg.SiteDomain),
fmt.Sprintf("CADDY_EMAIL=%s", cfg.CaddyEmail),
fmt.Sprintf("HTTP_PORT=%s", cfg.HTTPPort),
fmt.Sprintf("HTTPS_PORT=%s", cfg.HTTPSPort),
"",
fmt.Sprintf("POSTGRES_USER=%s", cfg.DBUser),
fmt.Sprintf("POSTGRES_PASSWORD=%s", cfg.DBPassword),
fmt.Sprintf("POSTGRES_DB=%s", cfg.DBName),
"",
fmt.Sprintf("DATABASE_URL=%s", dbURL),
"APP_PORT=8080",
"",
fmt.Sprintf("DB_HOST=%s", cfg.DBHost),
fmt.Sprintf("DB_PORT=%s", cfg.DBPort),
fmt.Sprintf("DB_SSLMODE=%s", cfg.DBSSLMode),
}
return strings.Join(lines, "\n") + "\n"
}
func DatabaseURL(cfg Config) string {
u := &url.URL{
Scheme: "postgres",
User: url.UserPassword(cfg.DBUser, cfg.DBPassword),
Host: fmt.Sprintf("%s:%s", cfg.DBHost, cfg.DBPort),
Path: cfg.DBName,
}
q := u.Query()
q.Set("sslmode", cfg.DBSSLMode)
u.RawQuery = q.Encode()
return u.String()
}
func buildCaddyfile(cfg Config) string {
email := cfg.CaddyEmail
if useLocalDomain(cfg.SiteDomain) {
return fmt.Sprintf(`{
email %s
}
:80 {
encode gzip zstd
@api path /health /version
handle @api {
reverse_proxy app:8080
}
handle /static/* {
reverse_proxy app:8080
}
handle {
reverse_proxy app:8080
}
log {
output stdout
format console
}
}
`, email)
}
domain := strings.TrimSpace(cfg.SiteDomain)
return fmt.Sprintf(`{
email %s
}
%s {
encode gzip zstd
@api path /health /version
handle @api {
reverse_proxy app:8080
}
handle /static/* {
reverse_proxy app:8080
}
handle {
reverse_proxy app:8080
}
log {
output stdout
format console
}
}
http://%s {
redir https://{host}{uri} permanent
}
`, email, domain, domain)
}
func useLocalDomain(d string) bool {
d = strings.ToLower(strings.TrimSpace(d))
return d == "" || d == "localhost" || d == "127.0.0.1" || d == "local"
}
func ask(in *bufio.Reader, prompt, def string) string {
if def != "" {
fmt.Printf("%s [%s]: ", prompt, def)
} else {
fmt.Printf("%s: ", prompt)
}
line, _ := in.ReadString('\n')
line = strings.TrimSpace(line)
if line == "" {
return def
}
return line
}
func askPassword(in *bufio.Reader, prompt string) string {
fmt.Printf("%s: ", prompt)
line, _ := in.ReadString('\n')
return strings.TrimSpace(line)
}
func askYesNo(in *bufio.Reader, prompt string, def bool) bool {
defStr := "y"
if !def {
defStr = "n"
}
for {
ans := strings.ToLower(ask(in, prompt+" (y/n)", defStr))
switch ans {
case "y", "yes", "д", "да":
return true
case "n", "no", "н", "нет":
return false
}
fmt.Println("Введите y или n")
}
}