feat(04-01): generate model, migration, and command artifacts

Add make:model, make:migration and make:command with plugin-id
inference, duplicate rejection, and a deterministic registry refresh
that leaves handwritten plugin.go untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-18 13:39:03 +02:00
parent 1edf9d7e4c
commit d8c56509f2
8 changed files with 1050 additions and 4 deletions

511
internal/build/artifact.go Normal file
View File

@@ -0,0 +1,511 @@
package build
import (
"context"
"fmt"
"go/token"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"unicode/utf8"
)
// ArtifactResult is the outcome of a make:* command.
type ArtifactResult struct {
PluginDir string
Files []string
Hint string
}
type artifactData struct {
PluginID string
Ident string
Table string
Func string
MigrationID string
UpSQL string
DownSQL string
CommandName string
Description string
}
var migrationNow = func() time.Time { return time.Now().UTC() }
// MakeModel writes a GORM model and, unless noMigration is set, a create-table migration.
func MakeModel(ctx context.Context, startDir, pluginID, name string, noMigration bool) (ArtifactResult, error) {
plugin, err := resolvePlugin(startDir, pluginID)
if err != nil {
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: model: %w", err)
}
table, err := modelTableName(plugin.ID, name)
if err != nil {
return ArtifactResult{}, err
}
modelRel := filepath.Join("models", toSnake(name)+".go")
if err := rejectDuplicateFile(plugin.Dir, modelRel); err != nil {
return ArtifactResult{}, err
}
if err := rejectDuplicateIdent(plugin.Dir, "models", name); err != nil {
return ArtifactResult{}, err
}
data := artifactData{
PluginID: plugin.ID,
Ident: name,
Table: table,
}
src, err := renderStub("model.go", data)
if err != nil {
return ArtifactResult{}, err
}
var created []string
rollback := true
defer func() {
if rollback {
for _, path := range created {
os.Remove(path)
}
}
}()
modelPath := filepath.Join(plugin.Dir, modelRel)
if err := writeFile(modelPath, src); err != nil {
return ArtifactResult{}, err
}
created = append(created, modelPath)
if !noMigration {
migFunc := "Create" + exportedPlural(name)
if err := rejectDuplicateIdent(plugin.Dir, "updates", migFunc); err != nil {
return ArtifactResult{}, err
}
migID, migRel, err := nextMigrationFile(plugin.Dir, "create_"+table)
if err != nil {
return ArtifactResult{}, err
}
migSrc, err := renderStub("migration.go", artifactData{
Func: migFunc,
MigrationID: migID,
UpSQL: createTableSQL(table),
DownSQL: "DROP TABLE IF EXISTS " + table,
})
if err != nil {
return ArtifactResult{}, err
}
migPath := filepath.Join(plugin.Dir, migRel)
if err := writeFile(migPath, migSrc); err != nil {
return ArtifactResult{}, err
}
created = append(created, migPath)
}
if err := finishArtifact(ctx, plugin); err != nil {
return ArtifactResult{}, err
}
rollback = false
return ArtifactResult{PluginDir: plugin.Dir, Files: created, Hint: plugin.Hint}, nil
}
// MakeMigration writes an ordered gormigrate stub in updates/.
func MakeMigration(ctx context.Context, startDir, pluginID, name string) (ArtifactResult, error) {
plugin, err := resolvePlugin(startDir, pluginID)
if err != nil {
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: migration: %w", err)
}
if err := rejectDuplicateIdent(plugin.Dir, "updates", name); err != nil {
return ArtifactResult{}, err
}
slug := toSnake(name)
migID, migRel, err := nextMigrationFile(plugin.Dir, slug)
if err != nil {
return ArtifactResult{}, err
}
src, err := renderStub("migration.go", artifactData{
Func: name,
MigrationID: migID,
UpSQL: "SELECT 1",
DownSQL: "SELECT 1",
})
if err != nil {
return ArtifactResult{}, err
}
path := filepath.Join(plugin.Dir, migRel)
if err := writeFile(path, src); err != nil {
return ArtifactResult{}, err
}
if err := finishArtifact(ctx, plugin); err != nil {
os.Remove(path)
return ArtifactResult{}, err
}
return ArtifactResult{PluginDir: plugin.Dir, Files: []string{path}, Hint: plugin.Hint}, nil
}
// MakeCommand writes a bonfire.Command stub in console/.
func MakeCommand(ctx context.Context, startDir, pluginID, name string) (ArtifactResult, error) {
plugin, err := resolvePlugin(startDir, pluginID)
if err != nil {
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: command: %w", err)
}
fn := name + "Command"
if err := rejectDuplicateIdent(plugin.Dir, "console", fn); err != nil {
return ArtifactResult{}, err
}
rel := filepath.Join("console", toSnake(name)+".go")
if err := rejectDuplicateFile(plugin.Dir, rel); err != nil {
return ArtifactResult{}, err
}
cmdName := pluginName(plugin.ID) + ":" + toSnake(name)
src, err := renderStub("command.go", artifactData{
Func: fn,
CommandName: cmdName,
Description: "TODO: describe " + cmdName,
})
if err != nil {
return ArtifactResult{}, err
}
path := filepath.Join(plugin.Dir, rel)
if err := writeFile(path, src); err != nil {
return ArtifactResult{}, err
}
if err := finishArtifact(ctx, plugin); err != nil {
os.Remove(path)
return ArtifactResult{}, err
}
return ArtifactResult{PluginDir: plugin.Dir, Files: []string{path}, Hint: plugin.Hint}, nil
}
type pluginRef struct {
Dir string
ID string
Hint string
}
func resolvePlugin(startDir, id string) (pluginRef, error) {
appDir, err := findAppDir(startDir)
if err != nil {
return pluginRef{}, err
}
if id == "" {
id, err = inferPluginID(startDir, appDir)
if err != nil {
return pluginRef{}, err
}
}
if err := ValidatePluginID(id); err != nil {
return pluginRef{}, fmt.Errorf("build: %w", err)
}
pluginDir, err := locatePluginDir(appDir, id)
if err != nil {
return pluginRef{}, err
}
if !underRoot(appDir, pluginDir) {
return pluginRef{}, fmt.Errorf("build: plugin directory %q is outside the app root", pluginDir)
}
got, err := pluginIDFromGo(filepath.Join(pluginDir, "plugin.go"))
if err != nil {
return pluginRef{}, err
}
if got != id {
return pluginRef{}, fmt.Errorf("build: plugin %s has id %s", pluginDir, got)
}
modPath, err := readModulePath(filepath.Join(pluginDir, "go.mod"))
if err != nil {
return pluginRef{}, err
}
if err := validateModulePath(modPath); err != nil {
return pluginRef{}, fmt.Errorf("build: plugin module: %w", err)
}
return pluginRef{
Dir: pluginDir,
ID: id,
Hint: accessorHint(filepath.Join(pluginDir, "plugin.go")),
}, nil
}
func inferPluginID(start, appDir string) (string, error) {
dir, err := filepath.Abs(start)
if err != nil {
return "", fmt.Errorf("build: resolve plugin directory: %w", err)
}
for {
candidate := filepath.Join(dir, "plugin.go")
if st, err := os.Stat(candidate); err == nil && !st.IsDir() {
if !underRoot(appDir, dir) {
return "", fmt.Errorf("build: plugin.go at %s is outside the app root", dir)
}
return pluginIDFromGo(candidate)
}
if dir == appDir {
break
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", fmt.Errorf("build: plugin id omitted and no plugin.go found from %s", start)
}
func locatePluginDir(appDir, id string) (string, error) {
m, err := LoadManifest(filepath.Join(appDir, manifestName))
if err != nil {
return "", err
}
for _, p := range m.Plugins {
if p.ID != id {
continue
}
if dir := localModuleDir(appDir, p.Module); dir != "" {
if st, err := os.Stat(dir); err == nil && st.IsDir() {
return dir, nil
}
}
}
candidate := filepath.Join(appDir, "plugins", pluginName(id))
if !underRoot(appDir, candidate) {
return "", fmt.Errorf("build: plugin path %q escapes the app root", candidate)
}
st, err := os.Stat(candidate)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("build: plugin %s not found", id)
}
return "", fmt.Errorf("build: stat %s: %w", candidate, err)
}
if !st.IsDir() {
return "", fmt.Errorf("build: plugin %s not found", id)
}
return candidate, nil
}
func finishArtifact(ctx context.Context, plugin pluginRef) error {
if err := refreshRegistry(plugin.Dir); err != nil {
return err
}
if err := runGo(ctx, plugin.Dir, "mod", "tidy"); err != nil {
return err
}
return ensureToolchain(filepath.Join(plugin.Dir, "go.mod"))
}
func accessorHint(pluginGoPath string) string {
body, err := os.ReadFile(pluginGoPath)
if err != nil {
return ""
}
src := string(body)
needed := []string{
"generatedModels",
"generatedMigrations",
"generatedCommands",
"generatedJobs",
"generatedAdminControllers",
}
var missing []string
for _, name := range needed {
if !strings.Contains(src, name+"(") {
missing = append(missing, name+"()")
}
}
if len(missing) == 0 {
return ""
}
return "note: add " + strings.Join(missing, ", ") + " to plugin.go capability methods; handwritten slices should append these accessors"
}
func validateExportedIdent(name string) error {
if name == "" {
return fmt.Errorf("name is empty")
}
if strings.ContainsAny(name, "/\\ \t\r\n\"'`$;&|*?<>()[]{}") {
return fmt.Errorf("%q contains invalid characters", name)
}
if !token.IsIdentifier(name) {
return fmt.Errorf("%q is not a valid Go identifier", name)
}
if !token.IsExported(name) {
return fmt.Errorf("%q must be an exported identifier", name)
}
return nil
}
func rejectDuplicateFile(pluginDir, rel string) error {
path := filepath.Join(pluginDir, rel)
if !underRoot(pluginDir, path) {
return fmt.Errorf("build: artifact path %q escapes the plugin directory", rel)
}
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("build: %s already exists", rel)
} else if !os.IsNotExist(err) {
return fmt.Errorf("build: stat %s: %w", path, err)
}
return nil
}
func rejectDuplicateIdent(pluginDir, pkg, ident string) error {
refs, err := scanGeneratedIdents(filepath.Join(pluginDir, pkg), pkg, identKind(pkg))
if err != nil {
return err
}
for _, ref := range refs {
if ref.Ident == ident {
return fmt.Errorf("build: %s.%s already exists", pkg, ident)
}
}
return nil
}
func identKind(pkg string) identScanKind {
switch pkg {
case "models":
return scanStructs
case "updates":
return scanMigrationFuncs
case "console":
return scanCommandFuncs
case "jobs":
return scanJobFuncs
case "controllers":
return scanAdminFuncs
default:
return scanStructs
}
}
func nextMigrationFile(pluginDir, slug string) (id, rel string, err error) {
if err := validateTableName(slug); err != nil {
return "", "", err
}
updates := filepath.Join(pluginDir, "updates")
ts := migrationNow().Format("20060102150405")
for i := 0; i < 60; i++ {
id = fmt.Sprintf("%s_%s", ts, slug)
rel = filepath.Join("updates", id+".go")
path := filepath.Join(pluginDir, rel)
if !underRoot(pluginDir, path) {
return "", "", fmt.Errorf("build: artifact path %q escapes the plugin directory", rel)
}
_, err := os.Stat(path)
if os.IsNotExist(err) {
if err := os.MkdirAll(updates, 0o755); err != nil {
return "", "", fmt.Errorf("build: mkdir %s: %w", updates, err)
}
return id, rel, nil
}
if err != nil {
return "", "", fmt.Errorf("build: stat %s: %w", path, err)
}
next, parseErr := time.Parse("20060102150405", ts)
if parseErr != nil {
return "", "", fmt.Errorf("build: migration timestamp: %w", parseErr)
}
ts = next.Add(time.Second).Format("20060102150405")
}
return "", "", fmt.Errorf("build: could not allocate a unique migration id for %s", slug)
}
func modelTableName(pluginID, typeName string) (string, error) {
table := strings.ReplaceAll(pluginID, ".", "_") + "_" + pluralSnake(typeName)
if err := validateTableName(table); err != nil {
return "", err
}
return table, nil
}
func validateTableName(name string) error {
if name == "" {
return fmt.Errorf("build: table name is empty")
}
for _, r := range name {
if r == '_' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
return fmt.Errorf("build: table name %q contains invalid characters", name)
}
return nil
}
func createTableSQL(table string) string {
return "CREATE TABLE " + table + " (\n" +
" id BIGSERIAL PRIMARY KEY,\n" +
" created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n" +
" updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n" +
" )"
}
func toSnake(ident string) string {
var b strings.Builder
prevUpper := false
for i, r := range ident {
upper := unicode.IsUpper(r)
if upper && i > 0 && !prevUpper {
b.WriteByte('_')
}
b.WriteRune(unicode.ToLower(r))
prevUpper = upper
}
return b.String()
}
func pluralSnake(ident string) string {
return pluralize(toSnake(ident))
}
func exportedPlural(ident string) string {
return snakeToExported(pluralSnake(ident))
}
func pluralize(word string) string {
switch {
case word == "":
return word
case strings.HasSuffix(word, "s") || strings.HasSuffix(word, "x") || strings.HasSuffix(word, "z") ||
strings.HasSuffix(word, "ch") || strings.HasSuffix(word, "sh"):
return word + "es"
case strings.HasSuffix(word, "y"):
r, _ := utf8.DecodeLastRuneInString(strings.TrimSuffix(word, "y"))
if r != utf8.RuneError && !isVowel(r) {
return strings.TrimSuffix(word, "y") + "ies"
}
return word + "s"
default:
return word + "s"
}
}
func isVowel(r rune) bool {
switch unicode.ToLower(r) {
case 'a', 'e', 'i', 'o', 'u':
return true
default:
return false
}
}
func snakeToExported(snake string) string {
parts := strings.Split(snake, "_")
var b strings.Builder
for _, part := range parts {
if part == "" {
continue
}
r, size := utf8.DecodeRuneInString(part)
b.WriteRune(unicode.ToUpper(r))
b.WriteString(part[size:])
}
return b.String()
}

View File

@@ -410,6 +410,153 @@ func TestScaffoldPluginSmoke(t *testing.T) {
}
}
func TestScaffoldCoreArtifacts(t *testing.T) {
dir := copyHelloApp(t)
ctx := t.Context()
pluginDir, err := MakePlugin(ctx, dir, "golem15.demo")
if err != nil {
t.Fatal(err)
}
pluginBefore, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go"))
if err != nil {
t.Fatal(err)
}
model, err := MakeModel(ctx, dir, "golem15.demo", "Album", false)
if err != nil {
t.Fatal(err)
}
if model.Hint != "" {
t.Fatalf("new plugin printed accessor hint: %s", model.Hint)
}
if len(model.Files) != 2 {
t.Fatalf("make:model files = %v", model.Files)
}
albumSrc, err := os.ReadFile(filepath.Join(pluginDir, "models", "album.go"))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"type Album struct",
"CreatedAt",
"UpdatedAt",
`return "golem15_demo_albums"`,
} {
if !bytes.Contains(albumSrc, []byte(want)) {
t.Fatalf("album.go missing %s:\n%s", want, albumSrc)
}
}
createAlbum, err := filepath.Glob(filepath.Join(pluginDir, "updates", "*_create_golem15_demo_albums.go"))
if err != nil {
t.Fatal(err)
}
if len(createAlbum) != 1 {
t.Fatalf("create-table migration files = %v", createAlbum)
}
migSrc, err := os.ReadFile(createAlbum[0])
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"func CreateAlbums()",
"CREATE TABLE golem15_demo_albums",
"DROP TABLE IF EXISTS golem15_demo_albums",
} {
if !bytes.Contains(migSrc, []byte(want)) {
t.Fatalf("model migration missing %s:\n%s", want, migSrc)
}
}
widget, err := MakeModel(ctx, pluginDir, "", "Widget", true)
if err != nil {
t.Fatal(err)
}
if len(widget.Files) != 1 || !strings.HasSuffix(widget.Files[0], filepath.Join("models", "widget.go")) {
t.Fatalf("--no-migration files = %v", widget.Files)
}
widgetMigs, err := filepath.Glob(filepath.Join(pluginDir, "updates", "*widget*"))
if err != nil {
t.Fatal(err)
}
if len(widgetMigs) != 0 {
t.Fatalf("--no-migration wrote %v", widgetMigs)
}
if _, err := MakeMigration(ctx, dir, "golem15.demo", "AddWidgetIndex"); err != nil {
t.Fatal(err)
}
if _, err := MakeCommand(ctx, dir, "golem15.demo", "Purge"); err != nil {
t.Fatal(err)
}
cmdSrc, err := os.ReadFile(filepath.Join(pluginDir, "console", "purge.go"))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(cmdSrc, []byte(`"demo:purge"`)) {
t.Fatalf("command name missing:\n%s", cmdSrc)
}
if _, err := MakeModel(ctx, dir, "golem15.demo", "Album", false); err == nil {
t.Fatal("duplicate make:model want error")
}
reg1, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go"))
if err != nil {
t.Fatal(err)
}
if _, err := MakeCommand(ctx, dir, "golem15.demo", "Purge"); err == nil {
t.Fatal("duplicate make:command want error")
}
if err := refreshRegistry(pluginDir); err != nil {
t.Fatal(err)
}
reg2, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(reg1, reg2) {
t.Fatal("repeated generation changed registry.gen.go")
}
for _, want := range []string{
"&models.Album{}",
"&models.Widget{}",
"updates.CreateAlbums()",
"updates.AddWidgetIndex()",
"console.PurgeCommand()",
} {
if !bytes.Contains(reg1, []byte(want)) {
t.Fatalf("registry.gen.go missing %s:\n%s", want, reg1)
}
}
pluginAfter, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(pluginBefore, pluginAfter) {
t.Fatal("handwritten plugin.go was rewritten")
}
greeter, err := MakeCommand(ctx, filepath.Join(dir, "plugins", "greeter"), "", "Wave")
if err != nil {
t.Fatal(err)
}
if greeter.Hint == "" || !strings.Contains(greeter.Hint, "generatedCommands()") {
t.Fatalf("want accessor hint for handwritten plugin, got %q", greeter.Hint)
}
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if err := App(ctx, dir, &buf); err != nil {
t.Fatalf("app build: %v\n%s", err, buf.String())
}
vet := execGo(t, pluginDir, "vet", "./...")
if out, err := vet.CombinedOutput(); err != nil {
t.Fatalf("plugin go vet: %v\n%s", err, out)
}
}
func assertScaffoldFiles(t *testing.T, pluginDir string) {
t.Helper()
assertWinterPluginShape(t, pluginDir)

View File

@@ -3,16 +3,34 @@ package build
import (
"bytes"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strings"
)
const registryFileName = "registry.gen.go"
const (
registryFileName = "registry.gen.go"
generatedMark = "Code generated by summer make"
)
type identScanKind int
const (
scanStructs identScanKind = iota
scanMigrationFuncs
scanCommandFuncs
scanJobFuncs
scanAdminFuncs
)
type registryRef struct {
Package string
Ident string
SortKey string
}
type registryData struct {
@@ -59,6 +77,188 @@ func uniqueLeafImports(module string, groups ...[]registryRef) []string {
return imports
}
func refreshRegistry(pluginDir string) error {
pkg, err := packageNameOf(filepath.Join(pluginDir, "plugin.go"))
if err != nil {
return err
}
module, err := readModulePath(filepath.Join(pluginDir, "go.mod"))
if err != nil {
return err
}
data := registryData{Package: pkg, Module: module}
if data.Models, err = scanGeneratedIdents(filepath.Join(pluginDir, "models"), "models", scanStructs); err != nil {
return err
}
if data.Migrations, err = scanGeneratedIdents(filepath.Join(pluginDir, "updates"), "updates", scanMigrationFuncs); err != nil {
return err
}
if data.Commands, err = scanGeneratedIdents(filepath.Join(pluginDir, "console"), "console", scanCommandFuncs); err != nil {
return err
}
if data.Jobs, err = scanGeneratedIdents(filepath.Join(pluginDir, "jobs"), "jobs", scanJobFuncs); err != nil {
return err
}
if data.AdminControllers, err = scanGeneratedIdents(filepath.Join(pluginDir, "controllers"), "controllers", scanAdminFuncs); err != nil {
return err
}
sortRefs(data.Models)
sortRefs(data.Migrations)
sortRefs(data.Commands)
sortRefs(data.Jobs)
sortRefs(data.AdminControllers)
return writeRegistry(pluginDir, data)
}
func packageNameOf(path string) (string, error) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, parser.PackageClauseOnly)
if err != nil {
return "", fmt.Errorf("build: parse %s: %w", path, err)
}
if file.Name == nil || file.Name.Name == "" {
return "", fmt.Errorf("build: %s has no package name", path)
}
return file.Name.Name, nil
}
func scanGeneratedIdents(dir, pkg string, kind identScanKind) ([]registryRef, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("build: read %s: %w", dir, err)
}
fset := token.NewFileSet()
var refs []registryRef
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") || name == "doc.go" {
continue
}
path := filepath.Join(dir, name)
file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("build: parse %s: %w", path, err)
}
if !isGeneratedFile(file) {
continue
}
switch kind {
case scanStructs:
refs = append(refs, structRefs(pkg, name, file)...)
default:
want := resultTypeFor(kind)
refs = append(refs, funcRefs(pkg, name, file, want)...)
}
}
return refs, nil
}
func resultTypeFor(kind identScanKind) string {
switch kind {
case scanMigrationFuncs:
return "gormigrate.Migration"
case scanCommandFuncs:
return "bonfire.Command"
case scanJobFuncs:
return "pact.Job"
case scanAdminFuncs:
return "pact.AdminController"
default:
return ""
}
}
func isGeneratedFile(file *ast.File) bool {
for _, cg := range file.Comments {
for _, c := range cg.List {
if strings.Contains(c.Text, generatedMark) {
return true
}
}
}
return false
}
func structRefs(pkg, filename string, file *ast.File) []registryRef {
var refs []registryRef
for _, decl := range file.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range gd.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok || ts.Name == nil || !ts.Name.IsExported() {
continue
}
if _, ok := ts.Type.(*ast.StructType); !ok {
continue
}
refs = append(refs, registryRef{Package: pkg, Ident: ts.Name.Name, SortKey: ts.Name.Name})
}
}
_ = filename
return refs
}
func funcRefs(pkg, filename string, file *ast.File, want string) []registryRef {
var refs []registryRef
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv != nil || fn.Name == nil || !fn.Name.IsExported() || fn.Type == nil {
continue
}
if fn.Type.Params != nil && len(fn.Type.Params.List) > 0 {
continue
}
if fn.Type.Results == nil || len(fn.Type.Results.List) != 1 {
continue
}
if astTypeName(fn.Type.Results.List[0].Type) != want {
continue
}
refs = append(refs, registryRef{
Package: pkg,
Ident: fn.Name.Name,
SortKey: strings.TrimSuffix(filename, ".go"),
})
}
return refs
}
func astTypeName(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.StarExpr:
return astTypeName(t.X)
case *ast.SelectorExpr:
if id, ok := t.X.(*ast.Ident); ok && t.Sel != nil {
return id.Name + "." + t.Sel.Name
}
case *ast.Ident:
return t.Name
}
return ""
}
func sortRefs(refs []registryRef) {
sort.Slice(refs, func(i, j int) bool {
a, b := refs[i].SortKey, refs[j].SortKey
if a == "" {
a = refs[i].Ident
}
if b == "" {
b = refs[j].Ident
}
if a == b {
return refs[i].Ident < refs[j].Ident
}
return a < b
})
}
func writeRegistry(pluginDir string, data registryData) error {
data.prepare()
src, err := renderStub("registry.go", data)

View File

@@ -0,0 +1,61 @@
{{define "model.go"}}// Code generated by summer make. DO NOT EDIT.
package models
import "time"
// {{.Ident}} is a generated GORM model for {{.PluginID}}.
type {{.Ident}} struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
}
func ({{.Ident}}) TableName() string { return {{printf "%q" .Table}} }
{{end}}
{{define "migration.go"}}// Code generated by summer make. DO NOT EDIT.
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// {{.Func}} returns the {{.MigrationID}} gormigrate entry.
func {{.Func}}() *gormigrate.Migration {
return &gormigrate.Migration{
ID: {{printf "%q" .MigrationID}},
Migrate: func(tx *gorm.DB) error {
return tx.Exec({{printf "%q" .UpSQL}}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec({{printf "%q" .DownSQL}}).Error
},
}
}
{{end}}
{{define "command.go"}}// Code generated by summer make. DO NOT EDIT.
package console
import (
"context"
"git.golem15.com/golem15/summercms/bonfire"
)
// {{.Func}} returns the {{.CommandName}} console command.
func {{.Func}}() bonfire.Command {
return bonfire.Command{
Name: {{printf "%q" .CommandName}},
Description: {{printf "%q" .Description}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
out.Printf({{printf "%q" .CommandName}} + "\n")
return nil
},
}
}
{{end}}