Files
summercms/cmd/summer/main.go
Jakub Zych e8d3d724f5 feat(build): bake plugin metadata and the framework version into plugins.gen.go
- party.Info, party.Installed, party.Describe (additive; Plugin and Activate unchanged)
- summer build resolves plugin dirs with one go list, reads plugin.yaml, versions per D-07 (module version, git describe without --dirty, none for in-app plugins)
- build.EffectiveFramework: newest tag of a locally replaced or used framework checkout, else the go.mod require
- generated main calls party.Describe(app, PluginInfo, FrameworkVersion) before Activate; metadata failures warn and fall back to IDs
- summer new goldens and examples/hello regenerated
2026-10-29 17:12:13 +01:00

561 lines
20 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"git.golem15.com/golem15/summercms/internal/build"
"git.golem15.com/golem15/summercms/internal/dev"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/diary"
"git.golem15.com/golem15/summercms/modules/kiosk"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
func main() {
args := os.Args[1:]
g := bonfire.PeekGlobals(args)
diary.Bootstrap(os.Stderr, diary.FromGlobals(g))
root, err := bonfire.NewRoot("summer", toolCommands(), os.Stdout,
bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))
if err != nil {
os.Exit(bonfire.Fail(os.Stderr, "summer", err, g))
}
os.Exit(bonfire.Execute(context.Background(), root, args))
}
func toolCommands() []bonfire.Command {
return append([]bonfire.Command{
buildCommand(),
makePluginCommand(),
makeModelCommand(),
makeMigrationCommand(),
makeCommandCommand(),
makeJobCommand(),
makeAdminControllerCommand(),
addPluginCommand(),
pluginManifestCommand(),
newCommand(),
devCommand(),
parityProxyCommand(),
parityRecordCommand(),
parityReplayCommand(),
parityBroadcastsCommand(),
parityUpstreamCommand(),
docsBuildCommand(),
docsSyncCommand(),
docsServeCommand(),
}, delegateCommands()...)
}
func buildCommand() bonfire.Command {
return bonfire.Command{
Name: "build",
Description: "Generate plugin imports and build the app binary",
Long: `Reads summer.yaml of the application in the current directory (or the nearest
one above it), generates plugins.gen.go and main.go from it, and runs go build
into bin/<binary>, passing the manifest's build.tags as -tags. On a terminal a
spinner runs while go build works. It ends with "Built bin/<binary> in <time>";
a compile error is printed and summer exits 1. Do not edit the generated
files: every build rewrites them.`,
Examples: []bonfire.Example{
{Command: "build", Description: "Regenerate main.go and build bin/<binary>"},
{Command: "build -q", Description: "Build and print only errors"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
dir, err := os.Getwd()
if err != nil {
return err
}
var log bytes.Buffer
run := func() error { return build.App(ctx, dir, &log) }
if bonfire.IsTerminal(out) {
err = out.Spinner("Building", run)
} else {
err = run()
}
for _, line := range strings.SplitAfter(log.String(), "\n") {
if line == "" {
continue
}
if rest, ok := strings.CutPrefix(line, "warning: "); ok {
out.Warning(strings.TrimSuffix(rest, "\n"))
continue
}
if rest, ok := strings.CutPrefix(line, "built "); ok && err == nil {
out.Success("Built bin/" + strings.TrimSuffix(rest, "\n"))
continue
}
out.Printf("%s", line)
}
return err
},
}
}
func makePluginCommand() bonfire.Command {
return bonfire.Command{
Name: "make:plugin",
Description: "Scaffold a compiling plugin module from a vendor.plugin id",
Long: `Creates plugins/<vendor>/<plugin> in the current application: a Go module with plugin.go
(the plugin ID, Register and Boot) and go.mod, with a replace for a local
framework checkout when the application uses one. Register it with plugin:add
so the next summer build compiles it in. Run it inside an application; the
directory must not exist yet.`,
Examples: []bonfire.Example{
{Command: "make:plugin acme.blog", Description: "Create plugins/acme/blog with the ID acme.blog"},
{Command: "plugin:add plugins/acme/blog", Description: "Then register it in summer.yaml, go.mod and go.work"},
},
Args: []bonfire.Arg{{
Name: "id",
Description: "Plugin ID in vendor.plugin form",
Required: true,
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
id, ok := in.Argument("id")
if !ok || id == "" {
return fmt.Errorf("make:plugin requires a vendor.plugin id")
}
dir, err := os.Getwd()
if err != nil {
return err
}
created, err := build.MakePlugin(ctx, dir, id)
if err != nil {
return err
}
out.Success("Created " + created)
return nil
},
}
}
func makeModelCommand() bonfire.Command {
return bonfire.Command{
Name: "make:model",
Description: "Generate a GORM model and create-table migration",
Long: `Writes models/<name>.go with a GORM model and, unless --no-migration, a
create-table migration updates/<timestamp>_create_<table>.go, in the plugin
with this ID (or the plugin whose directory you are in, when the ID is left
out). The table is the plugin ID and the plural name in snake case, such as
acme_blog_posts. The plugin's registry.gen.go lists the new files, so the
plugin picks them up on the next build. Existing files and names are never
overwritten.`,
Examples: []bonfire.Example{
{Command: "make:model acme.blog Post", Description: "A Post model and its create-table migration"},
{Command: "make:model Post --no-migration", Description: "Inside plugins/acme/blog: only the model"},
},
Args: makeArtifactArgs(),
Flags: []bonfire.Flag{{
Name: "no-migration",
Description: "Omit the create-table migration",
Bare: true,
}},
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:model")
if err != nil {
return err
}
result, err := build.MakeModel(ctx, dir, id, name, flagTrue(in, "no-migration"))
if err != nil {
return err
}
return printArtifact(out, result)
},
}
}
func makeMigrationCommand() bonfire.Command {
return bonfire.Command{
Name: "make:migration",
Description: "Generate an ordered gormigrate stub",
Long: `Writes updates/<timestamp>_<name>.go with an empty gormigrate migration (up
and down steps to fill in) in the plugin with this ID or the plugin whose
directory you are in. The 14-digit timestamp keeps migrations in the order you
created them. registry.gen.go adds it to the plugin's migrations; run migrate
to apply it.`,
Examples: []bonfire.Example{
{Command: "make:migration acme.blog AddSlugToPosts", Description: "An empty migration in acme.blog"},
{Command: "make:migration AddSlugToPosts", Description: "The same from inside plugins/acme/blog"},
},
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:migration")
if err != nil {
return err
}
result, err := build.MakeMigration(ctx, dir, id, name)
if err != nil {
return err
}
return printArtifact(out, result)
},
}
}
func makeCommandCommand() bonfire.Command {
return bonfire.Command{
Name: "make:command",
Description: "Generate a plugin console command",
Long: `Writes console/<name>.go with a bonfire.Command named <plugin>:<name> in the
plugin with this ID or the plugin whose directory you are in; the generated
registry adds it to the commands the plugin returns. The generated function
receives the application the plugin kept from Boot (*backpack.App), which
gives it the config and the services plugins publish. A function with another
parameter, such as the porting guide's withDB for blog:publish, is returned
from the plugin's Commands by hand.`,
Examples: []bonfire.Example{
{Command: "make:command acme.blog Publish", Description: "A blog:publish command in acme.blog"},
{Command: "make:command Publish", Description: "The same from inside plugins/acme/blog"},
},
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:command")
if err != nil {
return err
}
result, err := build.MakeCommand(ctx, dir, id, name)
if err != nil {
return err
}
return printArtifact(out, result)
},
}
}
func makeJobCommand() bonfire.Command {
return bonfire.Command{
Name: "make:job",
Description: "Generate a plugin job without importing River",
Long: `Writes jobs/<name>.go with a queued job built with conga.Job (no River import)
in the plugin with this ID or the plugin whose directory you are in; the
generated registry adds it to the jobs the plugin returns. Dispatch it with
conga.Manager.Dispatch; queue:work or serve runs it.`,
Examples: []bonfire.Example{
{Command: "make:job acme.blog SendDigest", Description: "A SendDigest job in acme.blog"},
{Command: "make:job SendDigest", Description: "The same from inside plugins/acme/blog"},
},
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",
Long: `Writes controllers/<name>.go, its config_form.yaml and config_list.yaml, and
starter fields.yaml and columns.yaml under models/<model>/, in the plugin with
this ID or the plugin whose directory you are in. The model is the singular of
the name (Posts edits Post) unless --model names it. When models/ already has
that model, NewRecord returns it; otherwise NewRecord returns nil until you run
make:model and return it, so the screens stay closed until you finish it. The
list offers create and delete. The controller requires the permission
<plugin>.access_<name>, which you declare in the plugin's Permissions(). A
scaffolded plugin serves the YAML through AdminFS from registry.gen.go.`,
Examples: []bonfire.Example{
{Command: "make:admin-controller acme.blog Posts", Description: "A Posts controller for the Post model in acme.blog"},
{Command: "make:admin-controller Posts", Description: "The same from inside plugins/acme/blog"},
{Command: "make:admin-controller acme.blog People --model Person", Description: "A People controller for the Person model"},
},
Args: makeArtifactArgs(),
Flags: []bonfire.Flag{{
Name: "model",
Description: "The model the controller edits (default: the singular of the name)",
}},
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
}
model, _ := in.Flag("model")
result, err := build.MakeAdminController(ctx, dir, id, name, model)
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"},
{Name: "name", Description: "Exported Go identifier"},
}
}
func resolveMakeArgs(in bonfire.Input, cmd string) (id, name string, err error) {
args := in.Args()
switch len(args) {
case 2:
return args[0], args[1], nil
case 1:
return "", args[0], nil
default:
return "", "", fmt.Errorf("%s requires <vendor.plugin> <Name>, or <Name> inside a plugin directory", cmd)
}
}
func flagTrue(in bonfire.Input, name string) bool {
v, ok := in.Flag(name)
if !ok {
return false
}
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "true", "1", "yes":
return true
default:
return false
}
}
func printArtifact(out bonfire.Output, result build.ArtifactResult) error {
for _, path := range result.Files {
out.Success("Created " + path)
}
if result.Hint != "" {
out.Printf("%s\n", result.Hint)
}
return nil
}
func devCommand() bonfire.Command {
return bonfire.Command{
Name: "dev",
Description: "Watch sources and rebuild the app binary",
Long: `Builds the application in the current directory, runs bin/<binary> serve and
watches the application's Go, YAML, go.mod, go.work, summer.yaml and .env
files (bin/, tmp/, .git/, main.go and plugins.gen.go excluded). After a change
it rebuilds and restarts serve; a failed build keeps the running server.
The global options summer dev was given (-v, -q, --ansi, --no-ansi, -n,
--env) are passed on to serve. Ctrl+C stops serve, which prints its own
shutdown report, then prints the dev report (rebuilds, failed builds,
restarts, uptime) and exits 0.`,
Examples: []bonfire.Example{
{Command: "dev", Description: "Build, serve and rebuild on every change"},
{Command: "dev -v", Description: "Pass -v on to serve: per-plugin summary lines and info logs of every component"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
dir, err := os.Getwd()
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
return dev.Watch(ctx, dir, out, bonfire.GlobalsOf(out).Args())
},
}
}
func addPluginCommand() bonfire.Command {
return bonfire.Command{
Name: "plugin:add",
Description: "Register a local plugin module in the app manifest and workspace",
Long: `Adds a plugin that already exists on disk (a directory with plugin.go and
go.mod, for example one made by make:plugin or a shared plugin checked out as
a git submodule) to the application: its ID and module go into summer.yaml, a
require and a local replace into the application's go.mod, and the directory
into go.work. Run summer build afterwards. A plugin ID or module that is
already registered is an error.`,
Examples: []bonfire.Example{
{Command: "plugin:add plugins/acme/blog", Description: "Register the plugin in plugins/acme/blog"},
},
Args: []bonfire.Arg{{
Name: "dir",
Description: "Local plugin directory",
Required: true,
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
pluginDir, ok := in.Argument("dir")
if !ok || pluginDir == "" {
return fmt.Errorf("plugin:add requires a local plugin directory")
}
dir, err := os.Getwd()
if err != nil {
return err
}
if err := build.AddPlugin(ctx, dir, pluginDir); err != nil {
return err
}
out.Success("Added " + pluginDir + " to summer.yaml, go.mod and go.work; run summer build")
return nil
},
}
}
func pluginManifestCommand() bonfire.Command {
return bonfire.Command{
Name: "plugin:manifest",
Description: "Generate or check a plugin's plugin.yaml from its sources",
Long: `Writes plugin.yaml into a plugin directory (the current one by default). Every
field comes from the plugin's own sources: the ID and the required plugins
from what plugin.go's ID() and Requires() return (string literals or
constants, read without running any code), the module path and the framework
and plugin version minimums from go.mod, the package import path from the
directory, and the name and description from the README's H1 and the
paragraph after it. plugin.yaml is committed next to plugin.go and never
edited by hand: rerun the command after changing any of those sources.
--name and --description replace the README text and are kept in plugin.yaml
until changed again; --name="" clears the override. A README whose H1 is the
plugin ID needs --name. --pretend prints the file without writing it.
--check writes nothing and exits 1, printing the expected content, when
plugin.yaml is missing or differs from what the command would write.`,
Examples: []bonfire.Example{
{Command: "plugin:manifest plugins/acme/blog", Description: "Write plugins/acme/blog/plugin.yaml"},
{Command: "plugin:manifest --check", Description: "Fail when the current directory's plugin.yaml is missing or stale"},
{Command: "plugin:manifest --pretend", Description: "Print the manifest without writing it"},
{Command: `plugin:manifest --name="Blog"`, Description: "Use Blog as the display name instead of the README's H1"},
},
Args: []bonfire.Arg{{
Name: "dir",
Description: "Plugin directory, the one with plugin.go (default: the current directory)",
}},
Flags: []bonfire.Flag{
{Name: "check", Description: "Write nothing; exit 1 when plugin.yaml is missing or stale", Bare: true},
{Name: "pretend", Description: "Print the generated plugin.yaml instead of writing it", Bare: true},
{Name: "name", Description: "Display name override, kept in plugin.yaml; an empty value clears it"},
{Name: "description", Description: "Description override, kept in plugin.yaml; an empty value clears it"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
dir, ok := in.Argument("dir")
if !ok || dir == "" {
dir = "."
}
var opts kiosk.DeriveOptions
if v, ok := in.Flag("name"); ok {
opts.Name = &v
}
if v, ok := in.Flag("description"); ok {
opts.Description = &v
}
if bonfire.FlagBool(in, "check") {
if opts.Name != nil || opts.Description != nil {
return fmt.Errorf("plugin:manifest --check takes no --name or --description; run plugin:manifest without --check to change them")
}
err := kiosk.Check(ctx, dir)
var stale *kiosk.StaleError
if errors.As(err, &stale) {
out.Println("Expected " + stale.Path + ":")
out.Println(strings.TrimRight(string(stale.Want), "\n"))
}
if err != nil {
return err
}
out.Success(filepath.Join(dir, kiosk.FileName) + " is up to date")
return nil
}
if bonfire.FlagBool(in, "pretend") {
data, warnings, err := kiosk.Generate(ctx, dir, opts)
for _, w := range warnings {
out.Warning(w)
}
if err != nil {
return err
}
out.Println(strings.TrimRight(string(data), "\n"))
return nil
}
path, warnings, err := kiosk.Write(ctx, dir, opts)
for _, w := range warnings {
out.Warning(w)
}
if err != nil {
return err
}
out.Success("Wrote " + path)
return nil
},
}
}
func newCommand() bonfire.Command {
return bonfire.Command{
Name: "new",
Description: "Create a SummerCMS application in sm-<name>-app",
Long: `Creates sm-<name>-app in the current directory: summer.yaml, go.mod and go.work
(pointing at a local framework checkout found next to it, or --framework),
config/ including config/log.yaml, .env with a fresh app.key and
.env.example, compose.yaml for PostgreSQL, a starter plugin (unless
--no-plugin) and a git repository (unless --no-git). It prints the next steps:
start the database, summer build, summer migrate and summer serve.`,
Examples: []bonfire.Example{
{Command: "new acme", Description: "Create sm-acme-app with a starter plugin"},
{Command: "new acme --no-plugin --no-git", Description: "An empty application without a git repository"},
},
Args: []bonfire.Arg{{
Name: "name",
Description: "Application name: lower-case letters and digits, starting with a letter; not a Go keyword, main or internal",
Required: true,
}},
Flags: []bonfire.Flag{
{Name: "framework", Description: "Path to the framework checkout (default: a sibling summercms or summercms.go checkout)"},
{Name: "no-plugin", Description: "Do not create the starter plugin", Bare: true},
{Name: "no-git", Description: "Do not initialise a git repository", Bare: true},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
name, ok := in.Argument("name")
if !ok || name == "" {
return fmt.Errorf("new requires an application name")
}
dir, err := os.Getwd()
if err != nil {
return err
}
framework, _ := in.Flag("framework")
key, err := lagoon.GenerateAppKey()
if err != nil {
return fmt.Errorf("new: generate application key: %w", err)
}
_, err = build.NewApp(ctx, build.NewOptions{
Name: name,
Parent: dir,
Framework: framework,
NoPlugin: flagTrue(in, "no-plugin"),
AppKey: key,
NoGit: flagTrue(in, "no-git"),
}, out)
return err
},
}
}