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/. 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 }