feat(04-01): generate jobs and admin controllers, reject model imports
Scaffold pact.Job and pact.AdminController stubs with Winter YAML assets, and fail summer build when models imports a sibling package. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
81
internal/build/leaf.go
Normal file
81
internal/build/leaf.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user