330 lines
11 KiB
Go
330 lines
11 KiB
Go
package scaffold
|
|
|
|
import (
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"go/format"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"golang.org/x/mod/module"
|
|
"golang.org/x/mod/semver"
|
|
)
|
|
|
|
//go:embed templates moduleTemplates
|
|
var templateFiles embed.FS
|
|
|
|
// Options controls project generation.
|
|
type Options struct {
|
|
Dir string
|
|
Module string
|
|
FrameworkDir string
|
|
FrameworkVersion string
|
|
Database string
|
|
LogOutput string
|
|
LogFormat string
|
|
Storage string
|
|
Force bool
|
|
}
|
|
|
|
var files = map[string]string{
|
|
"internal/settings/config.go": "templates/modules-settings.go.tmpl",
|
|
"internal/settings/config_test.go": "templates/modules-settings_test.go.tmpl",
|
|
"etc/config.yaml": "templates/modules-config.yaml.tmpl",
|
|
|
|
"go.mod": "templates/go.mod.tmpl",
|
|
"README.md": "templates/README.md.tmpl",
|
|
"cmd/server/main.go": "templates/modules-main.go.tmpl",
|
|
"internal/model/user.go": "templates/internal-model-user.go.tmpl",
|
|
"internal/model/errors.go": "templates/internal-model-errors.go.tmpl",
|
|
"internal/repository/users.go": "templates/internal-repository-users.go.tmpl",
|
|
"internal/repository/memory.go": "templates/internal-repository-memory.go.tmpl",
|
|
"internal/servicecontext/context.go": "templates/internal-servicecontext-context.go.tmpl",
|
|
"internal/service/user.go": "templates/internal-service-user.go.tmpl",
|
|
"internal/handler/user.go": "templates/internal-handler-user.go.tmpl",
|
|
}
|
|
|
|
// Generate creates a GoOmni service skeleton.
|
|
func Generate(options Options) error {
|
|
if strings.TrimSpace(options.Dir) == "" {
|
|
return errors.New("project directory is required")
|
|
}
|
|
if strings.TrimSpace(options.Module) == "" {
|
|
return errors.New("module path is required")
|
|
}
|
|
if err := module.CheckImportPath(options.Module); err != nil {
|
|
return fmt.Errorf("invalid module path: %w", err)
|
|
}
|
|
modules := options.LogOutput != "" || options.LogFormat != "" || options.Storage != ""
|
|
logOutput, logFormat, storage := options.LogOutput, options.LogFormat, options.Storage
|
|
if logOutput == "" {
|
|
logOutput = "stderr"
|
|
}
|
|
if logFormat == "" {
|
|
logFormat = "json"
|
|
}
|
|
if storage == "" {
|
|
storage = "none"
|
|
}
|
|
if logOutput != "stdout" && logOutput != "stderr" && logOutput != "file" {
|
|
return fmt.Errorf("unsupported log output %q: choose stdout, stderr or file", logOutput)
|
|
}
|
|
if logFormat != "json" && logFormat != "text" {
|
|
return fmt.Errorf("unsupported log format %q: choose json or text", logFormat)
|
|
}
|
|
switch storage {
|
|
case "none", "local", "s3", "r2", "oss":
|
|
default:
|
|
return fmt.Errorf("unsupported object storage %q: choose none, local, s3, r2 or oss", storage)
|
|
}
|
|
database := strings.ToLower(strings.TrimSpace(options.Database))
|
|
if database == "" {
|
|
database = "memory"
|
|
}
|
|
if database != "memory" && database != "mysql" {
|
|
return fmt.Errorf("unsupported database %q: choose memory or mysql", database)
|
|
}
|
|
version := strings.TrimSpace(options.FrameworkVersion)
|
|
if version == "" {
|
|
version = currentFrameworkVersion()
|
|
if modules && version == defaultFrameworkVersion {
|
|
version = modulesFrameworkVersion
|
|
}
|
|
}
|
|
if !validFrameworkVersion(version) {
|
|
return fmt.Errorf("framework version %q must be a complete v0 or v1 semantic version", version)
|
|
}
|
|
|
|
directory, err := filepath.Abs(options.Dir)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve project directory: %w", err)
|
|
}
|
|
if err := checkTarget(directory, options.Force); err != nil {
|
|
return err
|
|
}
|
|
// An explicit remote version opts out of automatic local development mode.
|
|
replace := ""
|
|
if options.FrameworkDir != "" || options.FrameworkVersion == "" {
|
|
replace = frameworkReplace(directory, options.FrameworkDir)
|
|
}
|
|
if replace == "" && semver.Compare(version, modulesFrameworkVersion) < 0 && version != installedFrameworkVersion() {
|
|
return fmt.Errorf("module templates require %s or newer; use --framework-dir for unpublished source", modulesFrameworkVersion)
|
|
}
|
|
data := templateData{
|
|
Module: options.Module,
|
|
FrameworkVersion: version,
|
|
FrameworkReplace: replace,
|
|
Database: database,
|
|
Modules: modules, LogOutput: logOutput, LogFormat: logFormat, Storage: storage,
|
|
}
|
|
projectFiles := make(map[string]string, len(files)+3)
|
|
for path, source := range files {
|
|
projectFiles[path] = source
|
|
}
|
|
if database == "mysql" {
|
|
projectFiles["internal/repository/mysql.go"] = "templates/internal-repository-mysql.go.tmpl"
|
|
projectFiles["schema.sql"] = "templates/schema.sql.tmpl"
|
|
projectFiles["internal/servicecontext/databaseIntegration_test.go"] = "templates/databaseIntegration_test.go.tmpl"
|
|
}
|
|
if modules {
|
|
projectFiles["cmd/server/main.go"] = "templates/modules-main.go.tmpl"
|
|
projectFiles["internal/settings/config.go"] = "templates/modules-settings.go.tmpl"
|
|
projectFiles["internal/settings/config_test.go"] = "templates/modules-settings_test.go.tmpl"
|
|
projectFiles["etc/config.yaml"] = "templates/modules-config.yaml.tmpl"
|
|
projectFiles[".gitignore"] = "templates/modules-gitignore.tmpl"
|
|
projectFiles["MODULES.md"] = "templates/modules-README.md.tmpl"
|
|
if storage != "none" {
|
|
projectFiles["internal/servicecontext/storage.go"] = "templates/modules-storage.go.tmpl"
|
|
projectFiles["internal/servicecontext/storage_test.go"] = "templates/modules-storage_test.go.tmpl"
|
|
projectFiles["internal/service/files.go"] = "templates/modules-filesService.go.tmpl"
|
|
projectFiles["internal/handler/files.go"] = "templates/modules-filesHandler.go.tmpl"
|
|
projectFiles["internal/handler/files_test.go"] = "templates/modules-filesHandler_test.go.tmpl"
|
|
}
|
|
}
|
|
if options.Force {
|
|
// Do not leave old generated Go files that reference dependencies omitted
|
|
// by this profile, and do not silently delete potentially edited code.
|
|
for _, path := range []string{"internal/repository/mysql.go", "internal/servicecontext/databaseIntegration_test.go", "schema.sql", "internal/settings/config.go", "internal/settings/config_test.go", "etc/config.yaml", "MODULES.md", "internal/servicecontext/storage.go", "internal/servicecontext/storage_test.go", "internal/service/files.go", "internal/handler/files.go", "internal/handler/files_test.go"} {
|
|
if _, emitted := projectFiles[path]; emitted {
|
|
continue
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(directory, path)); err == nil {
|
|
return fmt.Errorf("existing %s conflicts with the selected profile; generate into a new directory instead of removing existing code", path)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
paths := make([]string, 0, len(projectFiles))
|
|
for path := range projectFiles {
|
|
paths = append(paths, path)
|
|
}
|
|
sort.Strings(paths)
|
|
// Render and format the whole project before touching the target directory.
|
|
contents := make(map[string]string, len(paths))
|
|
for _, path := range paths {
|
|
content, err := render(projectFiles[path], data)
|
|
if err != nil {
|
|
return fmt.Errorf("render %s: %w", path, err)
|
|
}
|
|
if strings.HasSuffix(path, ".go") {
|
|
formatted, err := format.Source([]byte(content))
|
|
if err != nil {
|
|
return fmt.Errorf("format %s: %w", path, err)
|
|
}
|
|
content = string(formatted)
|
|
}
|
|
contents[path] = content
|
|
}
|
|
for _, path := range paths {
|
|
target := filepath.Join(directory, filepath.FromSlash(path))
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return fmt.Errorf("create %s: %w", filepath.Dir(path), err)
|
|
}
|
|
if !options.Force {
|
|
if _, err := os.Stat(target); err == nil {
|
|
return fmt.Errorf("file already exists: %s", target)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("check %s: %w", target, err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(target, []byte(contents[path]), 0o644); err != nil {
|
|
return fmt.Errorf("write %s: %w", target, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type templateData struct {
|
|
API bool
|
|
Module string
|
|
FrameworkVersion string
|
|
FrameworkReplace string
|
|
Database string
|
|
Modules bool
|
|
LogOutput string
|
|
LogFormat string
|
|
Storage string
|
|
}
|
|
|
|
func render(name string, data templateData) (string, error) {
|
|
content, err := fs.ReadFile(templateFiles, name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tmpl, err := newTemplate(name, string(content))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var output strings.Builder
|
|
if err := tmpl.Execute(&output, data); err != nil {
|
|
return "", err
|
|
}
|
|
return output.String(), nil
|
|
}
|
|
|
|
func newTemplate(name, content string) (*template.Template, error) {
|
|
return template.New(name).Parse(content)
|
|
}
|
|
|
|
func checkTarget(directory string, force bool) error {
|
|
info, err := os.Stat(directory)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("check project directory: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("project path is not a directory: %s", directory)
|
|
}
|
|
if force {
|
|
return nil
|
|
}
|
|
entries, err := os.ReadDir(directory)
|
|
if err != nil {
|
|
return fmt.Errorf("read project directory: %w", err)
|
|
}
|
|
if len(entries) > 0 {
|
|
return fmt.Errorf("project directory is not empty: %s (use --force to overwrite generated files)", directory)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func frameworkReplace(projectDir, configured string) string {
|
|
root := strings.TrimSpace(configured)
|
|
if root == "" {
|
|
root = findFrameworkRoot()
|
|
}
|
|
if root == "" {
|
|
return ""
|
|
}
|
|
projectDir = canonicalPath(projectDir)
|
|
absoluteRoot := canonicalPath(root)
|
|
relative, err := filepath.Rel(projectDir, absoluteRoot)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strconv.Quote(filepath.ToSlash(relative))
|
|
}
|
|
|
|
func canonicalPath(path string) string {
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return path
|
|
}
|
|
// Resolve the nearest existing ancestor too: generation now renders before
|
|
// creating directories, and macOS /var commonly resolves through /private.
|
|
candidate := abs
|
|
var tail []string
|
|
for {
|
|
resolved, err := filepath.EvalSymlinks(candidate)
|
|
if err == nil {
|
|
for i := len(tail) - 1; i >= 0; i-- {
|
|
resolved = filepath.Join(resolved, tail[i])
|
|
}
|
|
return resolved
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return abs
|
|
}
|
|
parent := filepath.Dir(candidate)
|
|
if parent == candidate {
|
|
return abs
|
|
}
|
|
tail = append(tail, filepath.Base(candidate))
|
|
candidate = parent
|
|
}
|
|
}
|
|
|
|
func findFrameworkRoot() string {
|
|
directory, err := os.Getwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for {
|
|
moduleFile := filepath.Join(directory, "go.mod")
|
|
content, readErr := os.ReadFile(moduleFile)
|
|
if readErr == nil {
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) >= 2 && fields[0] == "module" && strings.Trim(fields[1], "\"") == frameworkModule {
|
|
return directory
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
parent := filepath.Dir(directory)
|
|
if parent == directory {
|
|
return ""
|
|
}
|
|
directory = parent
|
|
}
|
|
}
|