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>
512 lines
13 KiB
Go
512 lines
13 KiB
Go
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()
|
|
}
|