From d8c56509f2800c70cf6a83a6dfcc274fc4d0d230 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 18 Sep 2026 13:39:03 +0200 Subject: [PATCH] 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 --- bonfire/command.go | 2 + bonfire/root.go | 9 +- cmd/summer/main.go | 120 +++++++ cmd/summer/main_test.go | 2 +- internal/build/artifact.go | 511 ++++++++++++++++++++++++++++ internal/build/build_test.go | 147 ++++++++ internal/build/registry.go | 202 ++++++++++- internal/build/stubs/artifacts.tmpl | 61 ++++ 8 files changed, 1050 insertions(+), 4 deletions(-) create mode 100644 internal/build/artifact.go create mode 100644 internal/build/stubs/artifacts.tmpl diff --git a/bonfire/command.go b/bonfire/command.go index c25da56..5e0551d 100644 --- a/bonfire/command.go +++ b/bonfire/command.go @@ -26,6 +26,8 @@ type Flag struct { Shorthand string Description string Default string + // Bare allows --flag without a value; cobra stores "true". + Bare bool } // Arg describes a positional argument. diff --git a/bonfire/root.go b/bonfire/root.go index 54d40c1..b1c1b64 100644 --- a/bonfire/root.go +++ b/bonfire/root.go @@ -65,9 +65,14 @@ func wrap(c Command, out Output) (*cobra.Command, error) { for _, flag := range c.Flags { if flag.Shorthand != "" { cmd.Flags().StringP(flag.Name, flag.Shorthand, flag.Default, flag.Description) - continue + } else { + cmd.Flags().String(flag.Name, flag.Default, flag.Description) + } + if flag.Bare { + if f := cmd.Flags().Lookup(flag.Name); f != nil { + f.NoOptDefVal = "true" + } } - cmd.Flags().String(flag.Name, flag.Default, flag.Description) } return cmd, nil } diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 514157c..e0b4bfb 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "git.golem15.com/golem15/summercms/bonfire" "git.golem15.com/golem15/summercms/internal/build" @@ -27,6 +28,9 @@ func toolCommands() []bonfire.Command { return []bonfire.Command{ buildCommand(), makePluginCommand(), + makeModelCommand(), + makeMigrationCommand(), + makeCommandCommand(), addPluginCommand(), devCommand(), parityProxyCommand(), @@ -81,6 +85,122 @@ func makePluginCommand() bonfire.Command { } } +func makeModelCommand() bonfire.Command { + return bonfire.Command{ + Name: "make:model", + Description: "Generate a GORM model and create-table migration", + Args: makeArtifactArgs(), + Flags: []bonfire.Flag{{ + Name: "no-migration", + Description: "Omit the create-table migration", + Bare: true, + }}, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + dir, err := os.Getwd() + if err != nil { + return err + } + id, name, err := resolveMakeArgs(in, "make:model") + if err != nil { + return err + } + result, err := build.MakeModel(ctx, dir, id, name, flagTrue(in, "no-migration")) + if err != nil { + return err + } + return printArtifact(out, result) + }, + } +} + +func makeMigrationCommand() bonfire.Command { + return bonfire.Command{ + Name: "make:migration", + Description: "Generate an ordered gormigrate stub", + Args: makeArtifactArgs(), + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + dir, err := os.Getwd() + if err != nil { + return err + } + id, name, err := resolveMakeArgs(in, "make:migration") + if err != nil { + return err + } + result, err := build.MakeMigration(ctx, dir, id, name) + if err != nil { + return err + } + return printArtifact(out, result) + }, + } +} + +func makeCommandCommand() bonfire.Command { + return bonfire.Command{ + Name: "make:command", + Description: "Generate a plugin console command", + Args: makeArtifactArgs(), + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + dir, err := os.Getwd() + if err != nil { + return err + } + id, name, err := resolveMakeArgs(in, "make:command") + if err != nil { + return err + } + result, err := build.MakeCommand(ctx, dir, id, name) + if err != nil { + return err + } + return printArtifact(out, result) + }, + } +} + +func makeArtifactArgs() []bonfire.Arg { + return []bonfire.Arg{ + {Name: "plugin", Description: "Plugin ID in vendor.plugin form, or the artifact name when already inside the plugin"}, + {Name: "name", Description: "Exported Go identifier"}, + } +} + +func resolveMakeArgs(in bonfire.Input, cmd string) (id, name string, err error) { + args := in.Args() + switch len(args) { + case 2: + return args[0], args[1], nil + case 1: + return "", args[0], nil + default: + return "", "", fmt.Errorf("%s requires , or inside a plugin directory", cmd) + } +} + +func flagTrue(in bonfire.Input, name string) bool { + v, ok := in.Flag(name) + if !ok { + return false + } + switch strings.ToLower(strings.TrimSpace(v)) { + case "", "true", "1", "yes": + return true + default: + return false + } +} + +func printArtifact(out bonfire.Output, result build.ArtifactResult) error { + for _, path := range result.Files { + out.Printf("created %s\n", path) + } + if result.Hint != "" { + out.Printf("%s\n", result.Hint) + } + return nil +} + func devCommand() bonfire.Command { return bonfire.Command{ Name: "dev", diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index 03e5ec4..203ce7b 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) { for _, c := range toolCommands() { names = append(names, c.Name) } - for _, want := range []string{"build", "make:plugin", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve"} { + for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve"} { if !slices.Contains(names, want) { t.Fatalf("missing %s in %v", want, names) } diff --git a/internal/build/artifact.go b/internal/build/artifact.go new file mode 100644 index 0000000..eba5e61 --- /dev/null +++ b/internal/build/artifact.go @@ -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() +} diff --git a/internal/build/build_test.go b/internal/build/build_test.go index dbbff3e..b4f8058 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -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) diff --git a/internal/build/registry.go b/internal/build/registry.go index 8ef3350..5a72624 100644 --- a/internal/build/registry.go +++ b/internal/build/registry.go @@ -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) diff --git a/internal/build/stubs/artifacts.tmpl b/internal/build/stubs/artifacts.tmpl new file mode 100644 index 0000000..f55a601 --- /dev/null +++ b/internal/build/stubs/artifacts.tmpl @@ -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}}