Files
summercms/internal/build/scaffold.go
Jakub Zych a95a014b52 fix(build): scaffold plugins under plugins/<vendor>/<name> (quick-261028-pq8)
- make:plugin writes plugins/<vendor>/<name> with module
  <app>/plugins/<vendor>/<name>; the package name stays <name>
- make:* and the models-leaf check look in summer.yaml, then
  plugins/<vendor>/<name>, then the older plugins/<name>
- summer new puts its starter plugin at plugins/golem15/<name>
- refuse a vendor directory that is a legacy plugin module and a second
  copy of a plugin that already sits in plugins/<name>
- help text and docs, with an upgrading note: moving is optional
2026-10-28 18:55:46 +01:00

636 lines
17 KiB
Go

package build
import (
"bytes"
"context"
"embed"
"fmt"
"go/ast"
"go/format"
"go/parser"
"go/token"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"text/template"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
//go:embed stubs/*.tmpl
var stubFS embed.FS
var stubTmpl = template.Must(template.ParseFS(stubFS, "stubs/*.tmpl"))
var pluginLeaves = []string{
"models",
"classes",
"controllers",
"console",
"jobs",
"middleware",
"updates",
}
type stubData struct {
Package string
ID string
Module string
Framework string
GoVersion string
Toolchain string
Replace string
// StarterRoute renders routes.go with a GET / route (summer new).
StarterRoute bool
}
// pluginOptions tunes the scaffold makePlugin writes. The zero value is the
// plain make:plugin output.
type pluginOptions struct {
StarterRoute bool
}
const (
frameworkModule = "git.golem15.com/golem15/summercms"
goVersionLine = "go 1.27.0"
toolchainLine = "toolchain go1.27.0"
)
// MakePlugin scaffolds a compiling plugin module under plugins/<vendor>/<name>
// for the ID <vendor>.<name>; its module path is the application module
// followed by /plugins/<vendor>/<name>, and its Go package is <name>.
func MakePlugin(ctx context.Context, startDir, id string) (string, error) {
return makePlugin(ctx, startDir, id, pluginOptions{})
}
func makePlugin(ctx context.Context, startDir, id string, opts pluginOptions) (string, error) {
if err := ValidatePluginID(id); err != nil {
return "", bonfire.WithHint(fmt.Errorf("build: %w", err),
"A plugin ID is the vendor and the plugin name in lower case letters and digits, joined by a dot.",
"{bin} make:plugin acme.blog")
}
appDir, err := findAppDir(startDir)
if err != nil {
return "", err
}
vendor, name := pluginVendor(id), pluginName(id)
pluginDir := filepath.Join(appDir, "plugins", vendor, name)
if !underRoot(appDir, pluginDir) {
return "", fmt.Errorf("build: plugin path %q escapes the app root", pluginDir)
}
addArg := "plugins/" + vendor + "/" + name
if _, err := os.Stat(pluginDir); err == nil {
return "", bonfire.WithHint(fmt.Errorf("build: plugin directory %s already exists", pluginDir),
"summer make:plugin creates a new directory under plugins/ and never overwrites one; register an existing plugin with plugin:add.",
"{bin} plugin:add "+addArg)
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("build: stat %s: %w", pluginDir, err)
}
// A legacy plugins/<vendor> module would swallow the new directory, and a
// legacy plugins/<name> with this ID is the same plugin.
vendorDir := filepath.Join(appDir, "plugins", vendor)
if _, err := os.Stat(filepath.Join(vendorDir, "go.mod")); err == nil {
return "", bonfire.WithHint(fmt.Errorf("build: %s is a plugin module, not a vendor directory", vendorDir),
"summer make:plugin writes plugins/<vendor>/<name>; plugins/"+vendor+" already holds a plugin in the older plugins/<name> layout. Move that plugin first, or choose another vendor.",
"{bin} make:plugin <vendor>."+name)
}
legacyDir := filepath.Join(appDir, "plugins", name)
if got, err := pluginIDFromGo(filepath.Join(legacyDir, "plugin.go")); err == nil && got == id {
return "", bonfire.WithHint(fmt.Errorf("build: plugin %s already exists in %s", id, legacyDir),
"summer make:plugin never creates a second copy of a plugin; register the existing one with plugin:add.",
"{bin} plugin:add plugins/"+name)
}
m, err := LoadManifest(filepath.Join(appDir, manifestName))
if err != nil {
return "", err
}
modPath := m.Module + "/" + addArg
if err := validateModulePath(modPath); err != nil {
return "", fmt.Errorf("build: plugin module: %w", err)
}
replace, err := frameworkReplaceFor(pluginDir, appDir)
if err != nil {
return "", err
}
data := stubData{
Package: name,
ID: id,
Module: modPath,
Framework: frameworkModule,
GoVersion: goVersionLine,
Toolchain: toolchainLine,
Replace: replace,
StarterRoute: opts.StarterRoute,
}
if err := writePluginScaffold(pluginDir, data); err != nil {
return "", err
}
if err := runGo(ctx, pluginDir, "mod", "tidy"); err != nil {
return "", err
}
if err := ensureToolchain(filepath.Join(pluginDir, "go.mod")); err != nil {
return "", err
}
return pluginDir, nil
}
// AddPlugin registers a local plugin module in summer.yaml, go.work and the app go.mod.
func AddPlugin(ctx context.Context, startDir, pluginDir string) error {
appDir, err := findAppDir(startDir)
if err != nil {
return err
}
if pluginDir == "" {
return fmt.Errorf("build: plugin directory is empty")
}
if !filepath.IsAbs(pluginDir) {
pluginDir = filepath.Join(startDir, pluginDir)
}
pluginDir, err = filepath.Abs(pluginDir)
if err != nil {
return fmt.Errorf("build: resolve plugin directory: %w", err)
}
if !underRoot(appDir, pluginDir) {
return fmt.Errorf("build: plugin directory %q is outside the app root", pluginDir)
}
modPath, err := readModulePath(filepath.Join(pluginDir, "go.mod"))
if err != nil {
return err
}
if err := validateModulePath(modPath); err != nil {
return fmt.Errorf("build: plugin module: %w", err)
}
id, err := pluginIDFromGo(filepath.Join(pluginDir, "plugin.go"))
if err != nil {
return err
}
if err := ValidatePluginID(id); err != nil {
return fmt.Errorf("build: %w", err)
}
manifestPath := filepath.Join(appDir, manifestName)
m, err := LoadManifest(manifestPath)
if err != nil {
return err
}
already := false
for _, p := range m.Plugins {
if p.ID == id && p.Module == modPath {
already = true
continue
}
if p.ID == id {
return fmt.Errorf("build: plugin id %q is already registered as %s", id, p.Module)
}
if p.Module == modPath {
return fmt.Errorf("build: plugin module %q is already registered as %s", modPath, p.ID)
}
}
if !already {
m.Plugins = append(m.Plugins, Plugin{ID: id, Module: modPath})
}
if err := editAppModule(ctx, appDir, pluginDir, modPath); err != nil {
return err
}
if err := ensureGoWork(ctx, appDir, m, pluginDir); err != nil {
return err
}
if already {
return nil
}
return SaveManifest(manifestPath, m)
}
func pluginName(id string) string {
_, name, _ := strings.Cut(id, ".")
return name
}
// pluginVendor returns the vendor part of a plugin ID, before the dot.
func pluginVendor(id string) string {
vendor, _, _ := strings.Cut(id, ".")
return vendor
}
// pluginDirCandidates lists the default directories of a plugin that
// summer.yaml does not place, in lookup order: plugins/<vendor>/<name>, the
// layout make:plugin writes, then the older plugins/<name>.
func pluginDirCandidates(appDir, id string) []string {
return []string{
filepath.Join(appDir, "plugins", pluginVendor(id), pluginName(id)),
filepath.Join(appDir, "plugins", pluginName(id)),
}
}
// defaultPluginDir returns the first of pluginDirCandidates that is an
// existing directory, or "" when none is. A candidate outside the app root is
// an error.
func defaultPluginDir(appDir, id string) (string, error) {
for _, dir := range pluginDirCandidates(appDir, id) {
if !underRoot(appDir, dir) {
return "", fmt.Errorf("build: plugin path %q escapes the app root", dir)
}
st, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return "", fmt.Errorf("build: stat %s: %w", dir, err)
}
if st.IsDir() {
return dir, nil
}
}
return "", nil
}
func writePluginScaffold(pluginDir string, data stubData) error {
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
return fmt.Errorf("build: create plugin dir: %w", err)
}
pluginSrc, err := renderStub("plugin.go", data)
if err != nil {
return err
}
if err := writeFile(filepath.Join(pluginDir, "plugin.go"), pluginSrc); err != nil {
return err
}
routesSrc, err := renderStub("routes.go", data)
if err != nil {
return err
}
if err := writeFile(filepath.Join(pluginDir, "routes.go"), routesSrc); err != nil {
return err
}
for _, leaf := range pluginLeaves {
src, err := renderStub(leaf+"/doc.go", data)
if err != nil {
return err
}
if err := writeFile(filepath.Join(pluginDir, leaf, "doc.go"), src); err != nil {
return err
}
}
for _, asset := range pluginAssets {
if err := writeFile(filepath.Join(pluginDir, filepath.FromSlash(asset.rel)), []byte(asset.body)); err != nil {
return err
}
}
if err := writeRegistry(pluginDir, emptyRegistry(data.Package, data.Module)); err != nil {
return err
}
modSrc, err := renderStub("go.mod", data)
if err != nil {
return err
}
return writeFile(filepath.Join(pluginDir, "go.mod"), modSrc)
}
type pluginAsset struct {
rel string
body string
}
var pluginAssets = []pluginAsset{
{rel: "config/config.yaml", body: "{}\n"},
{rel: "lang/en/lang.yaml", body: "{}\n"},
{rel: "views/mail/welcome.htm", body: "subject = \"Welcome\"\ndescription = \"Placeholder mail template\"\n==\nHello.\n"},
}
func renderStub(name string, data any) ([]byte, error) {
var buf bytes.Buffer
if err := stubTmpl.ExecuteTemplate(&buf, name, data); err != nil {
return nil, fmt.Errorf("build: render stub %s: %w", name, err)
}
raw := buf.Bytes()
if !strings.HasSuffix(name, ".go") {
return raw, nil
}
formatted, err := format.Source(raw)
if err != nil {
return nil, fmt.Errorf("build: format stub %s: %w\n%s", name, err, raw)
}
return formatted, nil
}
func writeFile(path string, data []byte) error {
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, data, 0o644); err != nil {
return fmt.Errorf("build: write %s: %w", path, err)
}
return nil
}
func frameworkReplaceFor(pluginDir, appDir string) (string, error) {
_, replaces, err := parseGoMod(filepath.Join(appDir, "go.mod"))
if err != nil {
return "", err
}
appReplace, ok := replaces[frameworkModule]
if !ok || appReplace == "" {
return "", nil
}
target := appReplace
if filepath.IsAbs(appReplace) {
return filepath.ToSlash(appReplace), nil
}
target = filepath.Join(appDir, target)
target, err = filepath.Abs(target)
if err != nil {
return "", fmt.Errorf("build: plugin replace: %w", err)
}
rel, err := filepath.Rel(pluginDir, target)
if err != nil {
return filepath.ToSlash(target), nil
}
return filepath.ToSlash(rel), nil
}
func pluginIDFromGo(path string) (string, error) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return "", fmt.Errorf("build: parse %s: %w", path, err)
}
var id string
ast.Inspect(file, func(n ast.Node) bool {
fn, ok := n.(*ast.FuncDecl)
if !ok || fn.Name == nil || fn.Name.Name != "ID" || fn.Recv == nil || fn.Body == nil {
return true
}
for _, stmt := range fn.Body.List {
ret, ok := stmt.(*ast.ReturnStmt)
if !ok || len(ret.Results) != 1 {
continue
}
lit, ok := ret.Results[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
s, err := strconv.Unquote(lit.Value)
if err == nil {
id = s
}
}
return true
})
if id == "" {
return "", fmt.Errorf("build: %s does not return a plugin id", path)
}
return id, nil
}
func editAppModule(ctx context.Context, appDir, pluginDir, modPath string) error {
if err := runGo(ctx, appDir, "mod", "edit", "-require="+modPath+"@v0.0.0"); err != nil {
return err
}
rel, err := relModulePath(appDir, pluginDir)
if err != nil {
return fmt.Errorf("build: plugin replace: %w", err)
}
if err := runGo(ctx, appDir, "mod", "edit", "-replace="+modPath+"="+rel); err != nil {
return err
}
return ensureToolchain(filepath.Join(appDir, "go.mod"))
}
func ensureGoWork(ctx context.Context, appDir string, m Manifest, pluginDir string) error {
gw := findGoWork(appDir)
if gw == "" {
if err := writeGoWork(filepath.Join(appDir, "go.work")); err != nil {
return err
}
gw = filepath.Join(appDir, "go.work")
for _, p := range m.Plugins {
if dir := localModuleDir(appDir, p.Module); dir != "" && dir != pluginDir {
if err := runGoWorkUse(ctx, gw, dir); err != nil {
return err
}
}
}
}
return runGoWorkUse(ctx, gw, pluginDir)
}
func writeGoWork(path string) error {
body := goVersionLine + "\n\n" + toolchainLine + "\n\nuse (\n\t.\n)\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
return fmt.Errorf("build: write go.work: %w", err)
}
return nil
}
// relModulePath returns target relative to base as a slash-separated local
// module path: "./x", "../x", "." or "..". Go reads a replace or use path
// without a ./ or ../ prefix as a module path, so the prefix is required.
func relModulePath(base, target string) (string, error) {
rel, err := filepath.Rel(base, target)
if err != nil {
return "", err
}
rel = filepath.ToSlash(rel)
if rel != "." && rel != ".." && !strings.HasPrefix(rel, "../") && !filepath.IsAbs(rel) && !strings.HasPrefix(rel, "./") {
rel = "./" + rel
}
return rel, nil
}
// runGoWorkUse adds dir to goWork with a path relative to the go.work
// directory, so a committed go.work never carries a machine-specific
// absolute path.
func runGoWorkUse(ctx context.Context, goWork, dir string) error {
workDir := filepath.Dir(goWork)
arg := dir
if rel, err := relModulePath(workDir, dir); err == nil {
arg = rel
}
cmd := exec.CommandContext(ctx, "go", "work", "use", arg)
cmd.Dir = workDir
applyGoWorkFile(cmd, goWork)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("build: go work use: %w\n%s", err, out)
}
return ensureToolchain(goWork)
}
func localModuleDir(appDir, module string) string {
_, replaces, err := parseGoMod(filepath.Join(appDir, "go.mod"))
if err != nil {
return ""
}
r, ok := replaces[module]
if !ok || r == "" {
return ""
}
if filepath.IsAbs(r) {
return r
}
return filepath.Clean(filepath.Join(appDir, r))
}
func readModulePath(path string) (string, error) {
module, _, err := parseGoMod(path)
if err != nil {
return "", err
}
if module == "" {
return "", fmt.Errorf("build: %s has no module path", path)
}
return module, nil
}
func parseGoMod(path string) (string, map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", nil, fmt.Errorf("build: read %s: %w", path, err)
}
module := ""
replaces := make(map[string]string)
inReplace := false
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimSpace(raw)
if i := strings.Index(line, "//"); i >= 0 {
line = strings.TrimSpace(line[:i])
}
if line == "" {
continue
}
if module == "" && strings.HasPrefix(line, "module ") {
module = strings.TrimSpace(strings.TrimPrefix(line, "module "))
continue
}
if line == "replace (" {
inReplace = true
continue
}
if inReplace {
if line == ")" {
inReplace = false
continue
}
if old, new, ok := splitReplace(line); ok {
replaces[old] = new
}
continue
}
if strings.HasPrefix(line, "replace ") {
if old, new, ok := splitReplace(strings.TrimPrefix(line, "replace ")); ok {
replaces[old] = new
}
}
}
return module, replaces, nil
}
func splitReplace(s string) (old, new string, ok bool) {
parts := strings.SplitN(s, "=>", 2)
if len(parts) != 2 {
return "", "", false
}
oldFields := strings.Fields(strings.TrimSpace(parts[0]))
newFields := strings.Fields(strings.TrimSpace(parts[1]))
if len(oldFields) == 0 || len(newFields) == 0 {
return "", "", false
}
return oldFields[0], newFields[0], true
}
func ensureToolchain(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("build: read %s: %w", path, err)
}
if bytes.Contains(data, []byte(toolchainLine)) {
return nil
}
lines := strings.Split(string(data), "\n")
out := make([]string, 0, len(lines)+2)
inserted := false
for _, line := range lines {
out = append(out, line)
if !inserted && strings.HasPrefix(strings.TrimSpace(line), "go ") {
out = append(out, "", toolchainLine)
inserted = true
}
}
if !inserted {
out = append([]string{toolchainLine, ""}, out...)
}
if err := os.WriteFile(path, []byte(strings.Join(out, "\n")), 0o644); err != nil {
return fmt.Errorf("build: write %s: %w", path, err)
}
return nil
}
func underRoot(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return false
}
return true
}
func findGoWork(start string) string {
dir := start
for {
candidate := filepath.Join(dir, "go.work")
if st, err := os.Stat(candidate); err == nil && !st.IsDir() {
return candidate
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
func runGo(ctx context.Context, dir string, args ...string) error {
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
applyGoWork(cmd, dir)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("build: go %s: %w\n%s", strings.Join(args, " "), err, out)
}
return nil
}
func applyGoWork(cmd *exec.Cmd, dir string) {
if gw := findGoWork(dir); gw != "" {
applyGoWorkFile(cmd, gw)
return
}
applyGoWorkFile(cmd, "off")
}
func applyGoWorkFile(cmd *exec.Cmd, goWork string) {
env := os.Environ()
filtered := make([]string, 0, len(env)+2)
for _, e := range env {
if strings.HasPrefix(e, "GOWORK=") || strings.HasPrefix(e, "GOFLAGS=") {
continue
}
filtered = append(filtered, e)
}
filtered = append(filtered, "GOWORK="+goWork)
if goWork == "off" {
filtered = append(filtered, "GOFLAGS=-mod=mod")
}
cmd.Env = filtered
}