Files
summercms/internal/build/artifact.go

655 lines
18 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
Worker string
Kind string
ConfigDir string
PluginPath string
Snake string
MigrationID string
UpSQL string
DownSQL string
CommandName string
Description string
Permission 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 typed job wrapped in conga.Job; the plugin never
// imports 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,
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,
Permission: plugin.ID + ".access_" + 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()
}