diff --git a/cmd/summer/main.go b/cmd/summer/main.go index e0b4bfb..6d38f04 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -31,6 +31,8 @@ func toolCommands() []bonfire.Command { makeModelCommand(), makeMigrationCommand(), makeCommandCommand(), + makeJobCommand(), + makeAdminControllerCommand(), addPluginCommand(), devCommand(), parityProxyCommand(), @@ -159,6 +161,52 @@ func makeCommandCommand() bonfire.Command { } } +func makeJobCommand() bonfire.Command { + return bonfire.Command{ + Name: "make:job", + Description: "Generate a plugin job without importing River", + 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:job") + if err != nil { + return err + } + result, err := build.MakeJob(ctx, dir, id, name) + if err != nil { + return err + } + return printArtifact(out, result) + }, + } +} + +func makeAdminControllerCommand() bonfire.Command { + return bonfire.Command{ + Name: "make:admin-controller", + Description: "Generate an admin controller with fields.yaml and columns.yaml", + 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:admin-controller") + if err != nil { + return err + } + result, err := build.MakeAdminController(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"}, diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index 203ce7b..1d95625 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", "make:model", "make:migration", "make:command", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve"} { + for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "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 index eba5e61..757ff4c 100644 --- a/internal/build/artifact.go +++ b/internal/build/artifact.go @@ -24,6 +24,9 @@ type artifactData struct { Ident string Table string Func string + Worker string + Kind string + ConfigDir string MigrationID string UpSQL string DownSQL string @@ -187,6 +190,124 @@ func MakeCommand(ctx context.Context, startDir, pluginID, name string) (Artifact return ArtifactResult{PluginDir: plugin.Dir, Files: []string{path}, Hint: plugin.Hint}, nil } +// MakeJob writes a pact.Job stub without importing River. +func MakeJob(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: job: %w", err) + } + fn := name + "Job" + if err := rejectDuplicateIdent(plugin.Dir, "jobs", fn); err != nil { + return ArtifactResult{}, err + } + rel := filepath.Join("jobs", toSnake(name)+".go") + if err := rejectDuplicateFile(plugin.Dir, rel); err != nil { + return ArtifactResult{}, err + } + src, err := renderStub("job.go", artifactData{ + Ident: name, + Func: fn, + Worker: unexportedIdent(name) + "Job", + Kind: plugin.ID + "." + toSnake(name), + }) + 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 +} + +// MakeAdminController writes a pact.AdminController and Winter-shaped YAML. +func MakeAdminController(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: admin-controller: %w", err) + } + fn := name + "Controller" + if err := rejectDuplicateIdent(plugin.Dir, "controllers", fn); err != nil { + return ArtifactResult{}, err + } + snake := toSnake(name) + rel := filepath.Join("controllers", snake+".go") + if err := rejectDuplicateFile(plugin.Dir, rel); err != nil { + return ArtifactResult{}, err + } + configDir := filepath.ToSlash(filepath.Join("controllers", snake)) + fieldsRel := filepath.Join("controllers", snake, "fields.yaml") + columnsRel := filepath.Join("controllers", snake, "columns.yaml") + for _, yamlRel := range []string{fieldsRel, columnsRel} { + if err := rejectDuplicateFile(plugin.Dir, yamlRel); err != nil { + return ArtifactResult{}, err + } + } + src, err := renderStub("admin_controller.go", artifactData{ + Ident: name, + Func: fn, + Worker: unexportedIdent(name) + "Admin", + Kind: plugin.ID + "." + snake, + ConfigDir: configDir, + }) + if err != nil { + return ArtifactResult{}, err + } + fieldsSrc, err := renderStub("fields.yaml", nil) + if err != nil { + return ArtifactResult{}, err + } + columnsSrc, err := renderStub("columns.yaml", nil) + if err != nil { + return ArtifactResult{}, err + } + + var created []string + rollback := true + defer func() { + if rollback { + for _, path := range created { + os.Remove(path) + } + } + }() + goPath := filepath.Join(plugin.Dir, rel) + if err := writeFile(goPath, src); err != nil { + return ArtifactResult{}, err + } + created = append(created, goPath) + fieldsPath := filepath.Join(plugin.Dir, fieldsRel) + if err := writeFile(fieldsPath, fieldsSrc); err != nil { + return ArtifactResult{}, err + } + created = append(created, fieldsPath) + columnsPath := filepath.Join(plugin.Dir, columnsRel) + if err := writeFile(columnsPath, columnsSrc); err != nil { + return ArtifactResult{}, err + } + created = append(created, columnsPath) + if err := finishArtifact(ctx, plugin); err != nil { + return ArtifactResult{}, err + } + rollback = false + return ArtifactResult{PluginDir: plugin.Dir, Files: created, Hint: plugin.Hint}, nil +} + +func unexportedIdent(name string) string { + r, size := utf8.DecodeRuneInString(name) + return string(unicode.ToLower(r)) + name[size:] +} + type pluginRef struct { Dir string ID string diff --git a/internal/build/build.go b/internal/build/build.go index 929d3b1..dbba10d 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -37,6 +37,9 @@ func App(ctx context.Context, appDir string, out io.Writer) error { if err := generate(appDir, m); err != nil { return err } + if err := checkModelsLeaf(appDir, m); err != nil { + return err + } binRel := filepath.Join("bin", m.Binary) if err := os.MkdirAll(filepath.Join(appDir, "bin"), 0o755); err != nil { diff --git a/internal/build/build_test.go b/internal/build/build_test.go index b4f8058..d083d86 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" ) @@ -557,6 +558,169 @@ func TestScaffoldCoreArtifacts(t *testing.T) { } } +func TestScaffoldAllArtifacts(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) + } + + if _, err := MakeModel(ctx, dir, "golem15.demo", "Album", false); err != nil { + t.Fatal(err) + } + if _, err := MakeMigration(ctx, dir, "golem15.demo", "AddAlbumIndex"); err != nil { + t.Fatal(err) + } + if _, err := MakeCommand(ctx, dir, "golem15.demo", "Purge"); err != nil { + t.Fatal(err) + } + job, err := MakeJob(ctx, dir, "golem15.demo", "Reindex") + if err != nil { + t.Fatal(err) + } + if len(job.Files) != 1 { + t.Fatalf("make:job files = %v", job.Files) + } + jobSrc, err := os.ReadFile(job.Files[0]) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "func (ReindexArgs) Kind()", + "func (reindexJob) Work(", + "unexpected args type", + } { + if !bytes.Contains(jobSrc, []byte(want)) { + t.Fatalf("job.go missing %s:\n%s", want, jobSrc) + } + } + if bytes.Contains(bytes.ToLower(jobSrc), []byte("river")) { + t.Fatal("job stub imports River") + } + + admin, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums") + if err != nil { + t.Fatal(err) + } + if len(admin.Files) != 3 { + t.Fatalf("make:admin-controller files = %v", admin.Files) + } + adminSrc, err := os.ReadFile(filepath.Join(pluginDir, "controllers", "albums.go")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `ID() string { return "golem15.demo.albums" }`, + `ModelName() string { return "Albums" }`, + `ConfigDir() string { return "controllers/albums" }`, + } { + if !bytes.Contains(adminSrc, []byte(want)) { + t.Fatalf("albums.go missing %s:\n%s", want, adminSrc) + } + } + for _, rel := range []string{ + filepath.Join("controllers", "albums", "fields.yaml"), + filepath.Join("controllers", "albums", "columns.yaml"), + } { + body, err := os.ReadFile(filepath.Join(pluginDir, rel)) + if err != nil { + t.Fatal(err) + } + key := "fields:" + if strings.Contains(rel, "columns") { + key = "columns:" + } + if !bytes.Contains(body, []byte(key)) { + t.Fatalf("%s missing %s:\n%s", rel, key, body) + } + } + + reg1, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "&models.Album{}", + "updates.CreateAlbums()", + "updates.AddAlbumIndex()", + "console.PurgeCommand()", + "jobs.ReindexJob()", + "controllers.AlbumsController()", + } { + if !bytes.Contains(reg1, []byte(want)) { + t.Fatalf("registry.gen.go missing %s:\n%s", want, reg1) + } + } + if _, err := MakeJob(ctx, dir, "golem15.demo", "Reindex"); err == nil { + t.Fatal("duplicate make:job 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") + } + 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") + } + + 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 TestModelsLeaf(t *testing.T) { + dir := copyHelloApp(t) + ctx := t.Context() + pluginDir, err := MakePlugin(ctx, dir, "golem15.demo") + if err != nil { + t.Fatal(err) + } + if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil { + t.Fatal(err) + } + modPath, err := readModulePath(filepath.Join(pluginDir, "go.mod")) + if err != nil { + t.Fatal(err) + } + bad := "package models\n\nimport " + strconv.Quote(modPath+"/classes") + "\n" + if err := os.WriteFile(filepath.Join(pluginDir, "models", "bad.go"), []byte(bad), 0o644); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + err = App(ctx, dir, &buf) + if err == nil { + t.Fatal("want models leaf import error") + } + msg := err.Error() + for _, want := range []string{"golem15.demo", "models/bad.go", modPath + "/classes"} { + if !strings.Contains(msg, want) { + t.Fatalf("leaf error %q missing %s", msg, want) + } + } +} + func assertScaffoldFiles(t *testing.T, pluginDir string) { t.Helper() assertWinterPluginShape(t, pluginDir) diff --git a/internal/build/leaf.go b/internal/build/leaf.go new file mode 100644 index 0000000..245bd17 --- /dev/null +++ b/internal/build/leaf.go @@ -0,0 +1,81 @@ +package build + +import ( + "fmt" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" +) + +var modelsSiblingLeaves = []string{ + "classes", + "controllers", + "console", + "jobs", + "middleware", + "updates", +} + +func checkModelsLeaf(appDir string, m Manifest) error { + for _, p := range m.Plugins { + pluginDir := localModuleDir(appDir, p.Module) + if pluginDir == "" { + pluginDir = filepath.Join(appDir, "plugins", pluginName(p.ID)) + } + if !underRoot(appDir, pluginDir) { + continue + } + modelsDir := filepath.Join(pluginDir, "models") + st, err := os.Stat(modelsDir) + if err != nil || !st.IsDir() { + continue + } + module, err := readModulePath(filepath.Join(pluginDir, "go.mod")) + if err != nil { + return err + } + if err := inspectModelsImports(p.ID, pluginDir, module, modelsDir); err != nil { + return err + } + } + return nil +} + +func inspectModelsImports(pluginID, pluginDir, module, modelsDir string) error { + entries, err := os.ReadDir(modelsDir) + if err != nil { + return fmt.Errorf("build: read %s: %w", modelsDir, err) + } + fset := token.NewFileSet() + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") { + continue + } + path := filepath.Join(modelsDir, name) + file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if err != nil { + return fmt.Errorf("build: parse %s: %w", path, err) + } + rel, err := filepath.Rel(pluginDir, path) + if err != nil { + rel = filepath.Join("models", name) + } + for _, spec := range file.Imports { + imp, err := strconv.Unquote(spec.Path.Value) + if err != nil { + continue + } + for _, sib := range modelsSiblingLeaves { + prefix := module + "/" + sib + if imp == prefix || strings.HasPrefix(imp, prefix+"/") { + return fmt.Errorf("build: plugin %s: %s imports %s", pluginID, filepath.ToSlash(rel), imp) + } + } + } + } + return nil +} diff --git a/internal/build/stubs/artifacts.tmpl b/internal/build/stubs/artifacts.tmpl index f55a601..e1f9647 100644 --- a/internal/build/stubs/artifacts.tmpl +++ b/internal/build/stubs/artifacts.tmpl @@ -59,3 +59,61 @@ func {{.Func}}() bonfire.Command { } } {{end}} + +{{define "job.go"}}// Code generated by summer make. DO NOT EDIT. + +package jobs + +import ( + "context" + "fmt" + + "git.golem15.com/golem15/summercms/pact" +) + +// {{.Ident}}Args is the typed payload for {{.Kind}}. +type {{.Ident}}Args struct{} + +func ({{.Ident}}Args) Kind() string { return {{printf "%q" .Kind}} } + +type {{.Worker}} struct{} + +func ({{.Worker}}) Work(ctx context.Context, args pact.JobArgs) error { + if _, ok := args.({{.Ident}}Args); !ok { + return fmt.Errorf("jobs: unexpected args type %T", args) + } + return nil +} + +// {{.Func}} returns the {{.Kind}} job. +func {{.Func}}() pact.Job { return {{.Worker}}{} } +{{end}} + +{{define "admin_controller.go"}}// Code generated by summer make. DO NOT EDIT. + +package controllers + +import "git.golem15.com/golem15/summercms/pact" + +type {{.Worker}} struct{} + +func ({{.Worker}}) ID() string { return {{printf "%q" .Kind}} } +func ({{.Worker}}) ModelName() string { return {{printf "%q" .Ident}} } +func ({{.Worker}}) ConfigDir() string { return {{printf "%q" .ConfigDir}} } + +// {{.Func}} returns the {{.Kind}} admin controller. +func {{.Func}}() pact.AdminController { return {{.Worker}}{} } +{{end}} + +{{define "fields.yaml"}}fields: + id: + label: ID + span: auto + type: number +{{end}} + +{{define "columns.yaml"}}columns: + id: + label: ID + searchable: true +{{end}}