Render plugin.go, routes.go, leaf packages and go.mod from embedded templates, emit empty registry accessors, and define job, admin, lang and mail capability contracts so a new plugin compiles before artifacts. Co-authored-by: Cursor <cursoragent@cursor.com>
542 lines
13 KiB
Go
542 lines
13 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"
|
|
)
|
|
|
|
//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
|
|
}
|
|
|
|
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/<name>.
|
|
func MakePlugin(ctx context.Context, startDir, id string) (string, error) {
|
|
if err := ValidatePluginID(id); err != nil {
|
|
return "", fmt.Errorf("build: %w", err)
|
|
}
|
|
appDir, err := findAppDir(startDir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
name := pluginName(id)
|
|
pluginDir := filepath.Join(appDir, "plugins", name)
|
|
if !underRoot(appDir, pluginDir) {
|
|
return "", fmt.Errorf("build: plugin path %q escapes the app root", pluginDir)
|
|
}
|
|
if _, err := os.Stat(pluginDir); err == nil {
|
|
return "", fmt.Errorf("build: plugin directory %s already exists", pluginDir)
|
|
} else if !os.IsNotExist(err) {
|
|
return "", fmt.Errorf("build: stat %s: %w", pluginDir, err)
|
|
}
|
|
|
|
m, err := LoadManifest(filepath.Join(appDir, manifestName))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
modPath := m.Module + "/plugins/" + name
|
|
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,
|
|
}
|
|
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
|
|
}
|
|
|
|
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 := filepath.Rel(appDir, pluginDir)
|
|
if err != nil {
|
|
return fmt.Errorf("build: plugin replace: %w", err)
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if rel != ".." && !strings.HasPrefix(rel, "../") && !filepath.IsAbs(rel) && !strings.HasPrefix(rel, "./") {
|
|
rel = "./" + rel
|
|
}
|
|
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, appDir, gw, dir); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return runGoWorkUse(ctx, appDir, 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
|
|
}
|
|
|
|
func runGoWorkUse(ctx context.Context, appDir, goWork, dir string) error {
|
|
cmd := exec.CommandContext(ctx, "go", "work", "use", dir)
|
|
cmd.Dir = appDir
|
|
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
|
|
}
|