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 Worker string Kind string ConfigDir string PluginPath string Snake 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 } // 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)) formRel := filepath.Join("controllers", snake, "config_form.yaml") listRel := filepath.Join("controllers", snake, "config_list.yaml") fieldsRel := filepath.Join("models", snake, "fields.yaml") columnsRel := filepath.Join("models", snake, "columns.yaml") for _, yamlRel := range []string{formRel, listRel, fieldsRel, columnsRel} { if err := rejectDuplicateFile(plugin.Dir, yamlRel); err != nil { return ArtifactResult{}, err } } data := artifactData{ Ident: name, Func: fn, Worker: unexportedIdent(name) + "Admin", Kind: plugin.ID + "." + snake, ConfigDir: configDir, PluginPath: strings.ReplaceAll(plugin.ID, ".", "/"), Snake: snake, } src, err := renderStub("admin_controller.go", data) if err != nil { return ArtifactResult{}, err } formSrc, err := renderStub("config_form.yaml", data) if err != nil { return ArtifactResult{}, err } listSrc, err := renderStub("config_list.yaml", data) 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) for _, item := range []struct { rel string body []byte }{ {formRel, formSrc}, {listRel, listSrc}, {fieldsRel, fieldsSrc}, {columnsRel, columnsSrc}, } { path := filepath.Join(plugin.Dir, item.rel) if err := writeFile(path, item.body); err != nil { return ArtifactResult{}, err } created = append(created, path) } 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 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() }