diff --git a/.env.example b/.env.example index 7b864f8..6860d98 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,17 @@ +# Скопируйте в .env или запустите: go run ./cmd/install + +SITE_DOMAIN=localhost +CADDY_EMAIL=admin@localhost +HTTP_PORT=80 +HTTPS_PORT=443 + POSTGRES_USER=shop POSTGRES_PASSWORD=shop_secret_change_me POSTGRES_DB=shopdb -HTTP_PORT=80 -HTTPS_PORT=443 +DATABASE_URL=postgres://shop:shop_secret_change_me@postgres:5432/shopdb?sslmode=require +APP_PORT=8080 -# Для HTTPS с Let's Encrypt в Caddyfile -# SITE_DOMAIN=shop.example.com -# CADDY_EMAIL=you@example.com +DB_HOST=postgres +DB_PORT=5432 +DB_SSLMODE=require diff --git a/Dockerfile b/Dockerfile index 4914bcc..7c730fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,8 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /app/server ./cmd/server +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ + -o /app/server ./cmd/server FROM alpine:3.20 diff --git a/README.md b/README.md index 8f41ddb..291de0c 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,87 @@ Главная страница интернет-магазина на Go с PostgreSQL 17 (SSL), reverse proxy Caddy и Docker Compose. -## Стек +Репозиторий: https://git.evilfox.cc/test/shop3.git -- Go 1.22, `pgx/v5` -- PostgreSQL 17 (SSL) -- Caddy 2 -- Docker Compose +## Быстрая установка на сервере -## Запуск +Требования: **Git**, **Docker**, **Docker Compose** (плагин `docker compose`). ```bash -cp .env.example .env +# 1. Клонировать +git clone https://git.evilfox.cc/test/shop3.git +cd shop3 + +# 2. Установщик (домен + база данных → .env и caddy/Caddyfile) +chmod +x install.sh check.sh +./install.sh + +# 3. Проверка версий +./check.sh + +# 4. Запуск docker compose up --build -d ``` -Сайт: http://localhost +Одной цепочкой (после клона введите ответы установщика): + +```bash +git clone https://git.evilfox.cc/test/shop3.git && cd shop3 && chmod +x install.sh check.sh && ./install.sh && ./check.sh && docker compose up --build -d +``` + +С Go на сервере вместо `install.sh`: + +```bash +go run ./cmd/install +go run ./cmd/check +``` + +Без Go — установщик сам запустится в контейнере `golang:1.22-alpine`. + +### Обновление на сервере + +```bash +cd shop3 +git pull +docker compose up --build -d +``` + +### Полезные команды + +```bash +docker compose ps # статус контейнеров +docker compose logs -f # логи +curl -s http://localhost/health | jq +curl -s http://localhost/version | jq +``` + +Сайт: `http://localhost` или `https://ваш-домен` (если указали в установщике). + +--- + +## Установка на Windows (локально) + +```powershell +git clone https://git.evilfox.cc/test/shop3.git +cd shop3 +.\install.ps1 +.\check.ps1 +docker compose up --build -d +``` + +## Проверка версий + +Проверяет Go, Docker, Docker Compose и PostgreSQL (**ожидается 17.x**): + +```bash +./check.sh +# или: go run ./cmd/check +``` + +После запуска сервера: + +- `GET /health` — статус и проверки +- `GET /version` — версии приложения, Go и PostgreSQL ## Локальная разработка @@ -24,4 +90,4 @@ docker compose up --build -d go run ./cmd/server ``` -Переменная `DATABASE_URL` обязательна (см. `.env.example`). +`DATABASE_URL` задаётся в `.env` (см. `.env.example` или установщик). diff --git a/caddy/Caddyfile b/caddy/Caddyfile index faba4e2..4b7d88b 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -6,8 +6,8 @@ :80 { encode gzip zstd - @health path /health - handle @health { + @api path /health /version + handle @api { reverse_proxy app:8080 } diff --git a/check.ps1 b/check.ps1 new file mode 100644 index 0000000..8be1d6b --- /dev/null +++ b/check.ps1 @@ -0,0 +1,4 @@ +# Проверка версий: Go, Docker, PostgreSQL +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot +go run ./cmd/check diff --git a/check.sh b/check.sh new file mode 100644 index 0000000..794f77e --- /dev/null +++ b/check.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e +cd "$(dirname "$0")" +if command -v go >/dev/null 2>&1; then + go run ./cmd/check +else + docker run --rm -v "$(pwd):/app" -w /app golang:1.22-alpine go run ./cmd/check +fi diff --git a/cmd/check/main.go b/cmd/check/main.go new file mode 100644 index 0000000..cdabf09 --- /dev/null +++ b/cmd/check/main.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/jackc/pgx/v5/pgxpool" + + "shop/internal/check" +) + +func main() { + loadDotEnv() + + ctx := context.Background() + report := check.AppInfo() + report.Items = append(report.Items, check.ToolVersions(ctx)...) + + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + report.Items = append(report.Items, check.Item{ + Name: "database", + Status: check.StatusWarn, + Detail: "DATABASE_URL не задан — запустите: go run ./cmd/install", + }) + } else { + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + report.Items = append(report.Items, check.Item{ + Name: "database", + Status: check.StatusError, + Detail: err.Error(), + }) + } else { + defer pool.Close() + dbItems, err := check.Database(ctx, pool) + if err != nil { + report.Items = append(report.Items, check.Item{ + Name: "database", + Status: check.StatusError, + Detail: err.Error(), + }) + } else { + report.Items = append(report.Items, dbItems...) + } + } + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(report) + + fmt.Println() + printSummary(report) + + if !report.Healthy() { + os.Exit(1) + } +} + +func printSummary(r check.Report) { + fmt.Printf("ShopNova %s | %s\n\n", r.AppVersion, r.GoVersion) + for _, it := range r.Items { + mark := "✓" + switch it.Status { + case check.StatusWarn: + mark = "!" + case check.StatusError: + mark = "✗" + } + line := fmt.Sprintf(" %s %-18s %s", mark, it.Name+":", it.Detail) + if it.Expected != "" { + line += " (ожидается " + it.Expected + ")" + } + fmt.Println(line) + } +} + +func loadDotEnv() { + root, _ := os.Getwd() + path := filepath.Join(root, ".env") + data, err := os.ReadFile(path) + if err != nil { + return + } + for _, line := range splitLines(string(data)) { + line = trimComment(line) + if line == "" { + continue + } + k, v, ok := splitKV(line) + if !ok { + continue + } + if os.Getenv(k) == "" { + _ = os.Setenv(k, v) + } + } +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} + +func trimComment(s string) string { + if i := indexByte(s, '#'); i >= 0 { + s = s[:i] + } + for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\r') { + s = s[:len(s)-1] + } + for len(s) > 0 && s[0] == ' ' { + s = s[1:] + } + return s +} + +func splitKV(s string) (string, string, bool) { + for i := 0; i < len(s); i++ { + if s[i] == '=' { + return s[:i], s[i+1:], true + } + } + return "", "", false +} + +func indexByte(s string, c byte) int { + for i := 0; i < len(s); i++ { + if s[i] == c { + return i + } + } + return -1 +} diff --git a/cmd/install/main.go b/cmd/install/main.go new file mode 100644 index 0000000..2716522 --- /dev/null +++ b/cmd/install/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "shop/internal/setup" +) + +func main() { + root, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "ошибка: %v\n", err) + os.Exit(1) + } + + if _, err := os.Stat(filepath.Join(root, "docker-compose.yml")); os.IsNotExist(err) { + fmt.Fprintln(os.Stderr, "запустите установщик из корня проекта (где docker-compose.yml)") + os.Exit(1) + } + + if _, err := setup.RunInteractive(root); err != nil { + fmt.Fprintf(os.Stderr, "установка не завершена: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 56ba985..ef7eed4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,10 +11,12 @@ import ( "syscall" "time" + "shop/internal/check" "shop/internal/config" "shop/internal/database" "shop/internal/handlers" "shop/internal/repository" + "shop/internal/version" "shop/internal/web" ) @@ -31,6 +33,17 @@ func main() { } defer pool.Close() + startupReport, err := check.WithDatabase(ctx, pool) + if err != nil { + log.Fatalf("version check: %v", err) + } + for _, it := range startupReport.Items { + if it.Name == "postgresql" && it.Status == check.StatusWarn { + log.Printf("warning: %s — %s", it.Name, it.Detail) + } + } + log.Printf("ShopNova %s | Go %s | PostgreSQL check OK", version.AppVersion, version.GoRuntime()) + tmpl, err := loadTemplates() if err != nil { log.Fatalf("templates: %v", err) @@ -38,6 +51,7 @@ func main() { products := repository.NewProductRepository(pool) home := handlers.NewHomeHandler(products, tmpl) + health := handlers.NewHealthHandler(pool) staticSub, err := fs.Sub(web.StaticFS, "static") if err != nil { @@ -46,7 +60,8 @@ func main() { mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) - mux.Handle("GET /health", http.HandlerFunc(handlers.Health)) + mux.HandleFunc("GET /health", health.Health) + mux.HandleFunc("GET /version", health.Version) mux.Handle("GET /", home) srv := &http.Server{ diff --git a/docker-compose.yml b/docker-compose.yml index 5137912..b7b1d18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,9 @@ services: container_name: shop-caddy depends_on: - app + environment: + SITE_DOMAIN: ${SITE_DOMAIN:-localhost} + CADDY_EMAIL: ${CADDY_EMAIL:-admin@localhost} ports: - "${HTTP_PORT:-80}:80" - "${HTTPS_PORT:-443}:443" diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..ba6391f --- /dev/null +++ b/install.ps1 @@ -0,0 +1,4 @@ +# Интерактивная установка: домен, база данных, .env, Caddyfile +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot +go run ./cmd/install diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..84e1f1d --- /dev/null +++ b/install.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e +cd "$(dirname "$0")" +if command -v go >/dev/null 2>&1; then + go run ./cmd/install +else + echo "Go не найден — запуск через Docker..." + docker run --rm -it -v "$(pwd):/app" -w /app golang:1.22-alpine go run ./cmd/install +fi diff --git a/internal/check/check.go b/internal/check/check.go new file mode 100644 index 0000000..f09e833 --- /dev/null +++ b/internal/check/check.go @@ -0,0 +1,176 @@ +package check + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "shop/internal/version" +) + +type Status string + +const ( + StatusOK Status = "ok" + StatusWarn Status = "warn" + StatusError Status = "error" +) + +type Item struct { + Name string `json:"name"` + Status Status `json:"status"` + Detail string `json:"detail"` + Expected string `json:"expected,omitempty"` +} + +type Report struct { + AppVersion string `json:"app_version"` + GoVersion string `json:"go_version"` + Items []Item `json:"checks"` +} + +func (r Report) Healthy() bool { + for _, it := range r.Items { + if it.Status == StatusError { + return false + } + } + return true +} + +func AppInfo() Report { + return Report{ + AppVersion: version.AppVersion, + GoVersion: version.GoRuntime(), + Items: []Item{ + { + Name: "go_runtime", + Status: goRuntimeStatus(), + Detail: version.GoRuntime(), + Expected: ">=" + version.MinGoVersion, + }, + }, + } +} + +func goRuntimeStatus() Status { + v := strings.TrimPrefix(version.GoRuntime(), "go") + major, minor, ok := parseGoVersion(v) + if !ok { + return StatusWarn + } + expMajor, expMinor, _ := parseGoVersion(version.MinGoVersion) + if major > expMajor || (major == expMajor && minor >= expMinor) { + return StatusOK + } + return StatusWarn +} + +func parseGoVersion(v string) (major, minor int, ok bool) { + parts := strings.Split(v, ".") + if len(parts) < 2 { + return 0, 0, false + } + major, err1 := strconv.Atoi(parts[0]) + minor, err2 := strconv.Atoi(parts[1]) + return major, minor, err1 == nil && err2 == nil +} + +func WithDatabase(ctx context.Context, pool *pgxpool.Pool) (Report, error) { + r := AppInfo() + dbItems, err := Database(ctx, pool) + if err != nil { + return r, err + } + r.Items = append(r.Items, dbItems...) + return r, nil +} + +func Database(ctx context.Context, pool *pgxpool.Pool) ([]Item, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + var pgVersion string + if err := pool.QueryRow(ctx, `SHOW server_version`).Scan(&pgVersion); err != nil { + return []Item{{ + Name: "database", + Status: StatusError, + Detail: err.Error(), + }}, nil + } + + major, ok := postgresMajor(pgVersion) + pgStatus := StatusOK + detail := pgVersion + expected := fmt.Sprintf("%d.x", version.ExpectedPostgresMajor) + + if !ok { + pgStatus = StatusWarn + detail = pgVersion + " (не удалось определить major)" + } else if major != version.ExpectedPostgresMajor { + pgStatus = StatusWarn + detail = fmt.Sprintf("%s (ожидается PostgreSQL %d)", pgVersion, version.ExpectedPostgresMajor) + } + + return []Item{ + {Name: "database", Status: StatusOK, Detail: "подключено"}, + {Name: "postgresql", Status: pgStatus, Detail: detail, Expected: expected}, + }, nil +} + +var pgMajorRe = regexp.MustCompile(`^(\d+)`) + +func postgresMajor(v string) (int, bool) { + m := pgMajorRe.FindStringSubmatch(strings.TrimSpace(v)) + if len(m) < 2 { + return 0, false + } + n, err := strconv.Atoi(m[1]) + return n, err == nil +} + +func ToolVersions(ctx context.Context) []Item { + var items []Item + if out, err := run(ctx, "docker", "version", "--format", "{{.Server.Version}}"); err == nil { + items = append(items, Item{Name: "docker", Status: StatusOK, Detail: strings.TrimSpace(out)}) + } else { + items = append(items, Item{Name: "docker", Status: StatusWarn, Detail: "не найден"}) + } + if out, err := run(ctx, "docker", "compose", "version", "--short"); err == nil { + items = append(items, Item{Name: "docker_compose", Status: StatusOK, Detail: strings.TrimSpace(out)}) + } else { + items = append(items, Item{Name: "docker_compose", Status: StatusWarn, Detail: "не найден"}) + } + return items +} + +func run(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.Output() + return string(out), err +} + +func Merge(reports ...Report) Report { + if len(reports) == 0 { + return AppInfo() + } + out := reports[0] + for _, r := range reports[1:] { + if out.AppVersion == "" { + out.AppVersion = r.AppVersion + } + if out.GoVersion == "" { + out.GoVersion = r.GoVersion + } + out.Items = append(out.Items, r.Items...) + } + return out +} diff --git a/internal/handlers/health.go b/internal/handlers/health.go new file mode 100644 index 0000000..d0e88c3 --- /dev/null +++ b/internal/handlers/health.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/jackc/pgx/v5/pgxpool" + + "shop/internal/check" +) + +type HealthHandler struct { + pool *pgxpool.Pool +} + +func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler { + return &HealthHandler{pool: pool} +} + +func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + report, err := check.WithDatabase(ctx, h.pool) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + "status": "error", + "error": err.Error(), + "version": report.AppVersion, + }) + return + } + + status := "ok" + code := http.StatusOK + if !report.Healthy() { + status = "degraded" + code = http.StatusServiceUnavailable + } + + writeJSON(w, code, map[string]any{ + "status": status, + "app_version": report.AppVersion, + "go_version": report.GoVersion, + "checks": report.Items, + }) +} + +func (h *HealthHandler) Version(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + report, _ := check.WithDatabase(ctx, h.pool) + writeJSON(w, http.StatusOK, report) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go index f1b0a85..e3756ff 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -63,8 +63,3 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { log.Printf("render home: %v", err) } } - -func Health(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":"ok"}`)) -} diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 0000000..79b250b --- /dev/null +++ b/internal/setup/setup.go @@ -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") + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..b0cfbf4 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,13 @@ +package version + +import "runtime" + +const ( + AppVersion = "1.0.0" + ExpectedPostgresMajor = 17 + MinGoVersion = "1.22" +) + +func GoRuntime() string { + return runtime.Version() +}