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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <vendor.plugin> <Name>, or <Name> 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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
511
internal/build/artifact.go
Normal file
511
internal/build/artifact.go
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
61
internal/build/stubs/artifacts.tmpl
Normal file
61
internal/build/stubs/artifacts.tmpl
Normal 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}}
|
||||
Reference in New Issue
Block a user