Files
summercms/internal/build/scaffold.go
Jakub Zych 6ea2cb70e0 fix(build): keep an existing toolchain line instead of adding a second one
make:*, plugin:add and new inserted toolchain go1.27.0 into go.mod and
go.work whenever that exact line was missing. A file that already named
another toolchain (go mod tidy and go work use write the running one)
ended up with two toolchain lines, and Go refused to load it with
"repeated toolchain statement". ensureToolchain now keeps an existing
line, replaces it only when it names an older toolchain, and inserts
one only when there is none.
2026-10-28 19:42:20 +01:00

655 lines
18 KiB
Go

package build
import (
"bytes"
"context"
"embed"
"fmt"
"go/ast"
"go/format"
"go/parser"
"go/token"
"go/version"
"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")
// A file may carry its own toolchain line (go mod tidy and go work use
// write the running toolchain). Go refuses a second one, so keep an
// existing line unless it names an older toolchain than ours.
want := strings.TrimSpace(strings.TrimPrefix(toolchainLine, "toolchain"))
for i, line := range lines {
fields := strings.Fields(line)
if len(fields) != 2 || fields[0] != "toolchain" {
continue
}
if version.Compare(fields[1], want) >= 0 {
return nil
}
lines[i] = toolchainLine
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
return fmt.Errorf("build: write %s: %w", path, err)
}
return nil
}
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
}