Files
summercms/internal/build/new.go
Jakub Zych c33ebf3b95 feat(14.3-01): detect the framework checkout and guard summer new targets
- resolve the framework from --framework, then a sibling summercms or
  summercms.go checkout, then the binary's own source tree, each accepted
  only when its go.mod declares the framework module
- refuse a missing parent and a target inside the framework checkout
- remove the half-written app when any step fails
- add --no-plugin for an app with no starter plugin
2026-10-08 11:40:20 +02:00

286 lines
9.2 KiB
Go

package build
import (
"context"
"errors"
"fmt"
"go/token"
"io"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
)
// appNameRe is the public `summer new <name>` rule: lower-case letters and
// digits, starting with a letter. The name is also the second part of the
// starter plugin ID golem15.<name>.
var appNameRe = regexp.MustCompile(`^[a-z][a-z0-9]*$`)
// NewOptions configures NewApp.
type NewOptions struct {
// Name is the application name; the app is written to sm-<Name>-app.
Name string
// Parent is the directory that receives sm-<Name>-app. Empty means the
// current directory.
Parent string
// Framework is the path to the framework checkout, resolved against
// Parent when relative. Empty means auto-detection: a sibling
// summercms or summercms.go checkout, then the source tree this binary
// was built from.
Framework string
// NoPlugin skips the starter plugin; summer.yaml then lists no plugins.
NoPlugin bool
}
// frameworkSiblings are the directory names auto-detection tries next to
// the new application. A candidate counts only when its go.mod declares the
// framework module.
var frameworkSiblings = []string{"summercms", "summercms.go"}
// frameworkFromBinary returns the framework checkout this binary was built
// from, or "" when unknown. Tests replace it.
var frameworkFromBinary = binarySourceFramework
// genFile is one file NewApp writes itself, relative to the app root.
type genFile struct {
Rel string
Body []byte
Mode os.FileMode
}
// newData feeds the new/* templates.
type newData struct {
Name string
Module string
Framework string
GoVersion string
Toolchain string
Replace string
}
// ValidateAppName reports whether name is usable as `summer new <name>`.
func ValidateAppName(name string) error {
if !appNameRe.MatchString(name) {
return fmt.Errorf("build: app name %q must be lower-case letters and digits, starting with a letter", name)
}
if token.IsKeyword(name) {
return fmt.Errorf("build: app name %q is a Go keyword; the starter plugin package would not compile", name)
}
switch name {
case "main":
return fmt.Errorf("build: app name %q is reserved: plugins/main would be a program, not an importable package", name)
case "internal":
return fmt.Errorf("build: app name %q is reserved: Go forbids importing plugins/internal from the app", name)
}
return nil
}
// appModulePath is the module path of a generated application.
func appModulePath(name string) string {
return "git.golem15.com/golem15/sm-" + name + "-app"
}
// appDirName is the directory a generated application lives in.
func appDirName(name string) string {
return "sm-" + name + "-app"
}
// NewApp writes a ready-to-run application to <Parent>/sm-<Name>-app and
// returns its path. It refuses a target that exists in any form.
func NewApp(ctx context.Context, opts NewOptions, out io.Writer) (string, error) {
if out == nil {
out = io.Discard
}
if err := ValidateAppName(opts.Name); err != nil {
return "", err
}
parent := opts.Parent
if parent == "" {
parent = "."
}
parent, err := filepath.Abs(parent)
if err != nil {
return "", fmt.Errorf("build: resolve parent directory: %w", err)
}
if st, err := os.Stat(parent); err != nil {
return "", fmt.Errorf("build: parent directory: %w", err)
} else if !st.IsDir() {
return "", fmt.Errorf("build: parent %s is not a directory", parent)
}
target := filepath.Join(parent, appDirName(opts.Name))
if _, err := os.Lstat(target); err == nil {
return "", fmt.Errorf("build: %s already exists; choose another name or remove it", target)
} else if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("build: stat %s: %w", target, err)
}
framework, err := resolveFramework(parent, opts.Framework)
if err != nil {
return "", err
}
if underRoot(framework, target) {
return "", fmt.Errorf("build: %s is inside the framework checkout %s; run summer new in the directory next to the checkout", target, framework)
}
files, err := planNewApp(opts, target, framework)
if err != nil {
return "", err
}
if err := os.Mkdir(target, 0o755); err != nil {
return "", fmt.Errorf("build: create %s: %w", target, err)
}
// target did not exist before this run, so a failure removes it and a
// re-run with the same name is not blocked.
if err := populateApp(ctx, opts, target, files); err != nil {
if rmErr := os.RemoveAll(target); rmErr != nil {
return "", fmt.Errorf("build: create %s: %w (removing it failed: %v)", target, err, rmErr)
}
return "", fmt.Errorf("build: create %s (removed it): %w", target, err)
}
fmt.Fprintf(out, "created %s\n", target)
return target, nil
}
// resolveFramework picks the framework checkout: the explicit flag (never
// falling through when it is wrong), then a sibling of parent whose go.mod
// declares the framework module, then the source tree of this binary. Paths
// stay lexical so a symlinked sibling yields a ../<sibling> replace.
func resolveFramework(parent, flag string) (string, error) {
if flag != "" {
dir := flag
if !filepath.IsAbs(dir) {
dir = filepath.Join(parent, dir)
}
dir = filepath.Clean(dir)
if err := checkFramework(dir); err != nil {
return "", fmt.Errorf("build: --framework %s: %w", flag, err)
}
return dir, nil
}
for _, name := range frameworkSiblings {
dir := filepath.Join(parent, name)
if checkFramework(dir) == nil {
return dir, nil
}
}
if dir := frameworkFromBinary(); dir != "" && checkFramework(dir) == nil {
return dir, nil
}
return "", fmt.Errorf("build: no framework checkout found: looked for %s next to %s; pass --framework <path>",
strings.Join(frameworkSiblings, " and "), parent)
}
// binarySourceFramework returns the repository root this file was compiled
// from. A -trimpath build records a module-relative path, which is ignored.
func binarySourceFramework() string {
_, file, _, ok := runtime.Caller(0)
if !ok || !filepath.IsAbs(file) {
return ""
}
// file is <root>/internal/build/new.go.
root := filepath.Dir(filepath.Dir(filepath.Dir(file)))
if checkFramework(root) != nil {
return ""
}
return root
}
// checkFramework requires dir/go.mod to declare the framework module.
func checkFramework(dir string) error {
module, _, err := parseGoMod(filepath.Join(dir, "go.mod"))
if err != nil {
return fmt.Errorf("framework checkout %s: %w", dir, err)
}
if module != frameworkModule {
return fmt.Errorf("%s declares module %q, not the framework %s", dir, module, frameworkModule)
}
return nil
}
// planNewApp renders every file NewApp writes from its own templates. It
// reads nothing but the framework go.mod, so the output can be compared
// byte for byte.
func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) {
if err := ValidateAppName(opts.Name); err != nil {
return nil, err
}
if err := checkFramework(framework); err != nil {
return nil, fmt.Errorf("build: %w", err)
}
replace, err := relModulePath(target, framework)
if err != nil {
return nil, fmt.Errorf("build: framework replace: %w", err)
}
data := newData{
Name: opts.Name,
Module: appModulePath(opts.Name),
Framework: frameworkModule,
GoVersion: goVersionLine,
Toolchain: toolchainLine,
Replace: replace,
}
plan := []struct {
rel string
tmpl string
mode os.FileMode
}{
{"go.mod", "new/go.mod", 0o644},
{"config/app.yaml", "new/config/app.yaml", 0o644},
{"config/database.yaml", "new/config/database.yaml", 0o644},
{"config/http.yaml", "new/config/http.yaml", 0o644},
{"config/storage.yaml", "new/config/storage.yaml", 0o644},
}
files := make([]genFile, 0, len(plan))
for _, p := range plan {
body, err := renderStub(p.tmpl, data)
if err != nil {
return nil, err
}
files = append(files, genFile{Rel: p.rel, Body: body, Mode: p.mode})
}
return files, nil
}
// populateApp writes the planned files, the workspace, the manifest and the
// starter plugin into target, then generates main.go and plugins.gen.go.
func populateApp(ctx context.Context, opts NewOptions, target string, files []genFile) error {
for _, f := range files {
path := filepath.Join(target, filepath.FromSlash(f.Rel))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("build: mkdir %s: %w", filepath.Dir(path), err)
}
if err := os.WriteFile(path, f.Body, f.Mode); err != nil {
return fmt.Errorf("build: write %s: %w", path, err)
}
}
// The app's own go.work comes before the plugin step so a go.work in an
// ancestor directory is never picked up.
if err := writeGoWork(filepath.Join(target, "go.work")); err != nil {
return err
}
manifestPath := filepath.Join(target, manifestName)
if err := SaveManifest(manifestPath, Manifest{Module: appModulePath(opts.Name), Binary: opts.Name}); err != nil {
return err
}
if !opts.NoPlugin {
if err := addStarterPlugin(ctx, opts.Name, target); err != nil {
return err
}
}
m, err := LoadManifest(manifestPath)
if err != nil {
return err
}
return generate(target, m)
}
// addStarterPlugin creates golem15.<name> through the make:plugin and
// plugin:add code paths, with the starter route.
func addStarterPlugin(ctx context.Context, name, target string) error {
if _, err := makePlugin(ctx, target, "golem15."+name, pluginOptions{StarterRoute: true}); err != nil {
return err
}
return AddPlugin(ctx, target, filepath.Join("plugins", name))
}