Files
summercms/cmd/summer/main.go
Jakub Zych 33460607db feat(summer): help, examples and hints for every tool command
- build runs go build under a spinner on a terminal and ends with
  'Built bin/<name> in <time>'; make:* print 'Created <path>' per file
- make:*, plugin:add, new, parity:*, docs:build, docs:sync and every
  delegate get long help and examples; delegates note that global options
  pass through to the application binary
- make:* failures (no summer.yaml, invalid plugin ID or name, existing
  artifact, unknown plugin) explain themselves and suggest a command
- docs:build and docs:sync return bonfire.Reported after the problem list
- TestCommandsDocumented covers the tool commands too
- docs: Upgrading to 0.4, delegates and global options, make: results and
  hints, the checklist for a finished command
2026-10-21 13:25:53 +02:00

457 lines
16 KiB
Go

package main
import (
"bytes"
"context"
"fmt"
"os"
"os/signal"
"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/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(),
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, "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/<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/blog with the ID acme.blog"},
{Command: "plugin:add plugins/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/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/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
takes no arguments, so a command that needs the database or another service
takes a parameter instead and is returned from the plugin's Commands by hand
(see the porting guide's blog:publish).`,
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/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/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/<name>/, in the plugin with
this ID or the plugin whose directory you are in. The controller requires the
permission <plugin>.access_<name>, which you declare in the plugin's
Permissions(), and its NewRecord returns nil until you return your model, so
the screens stay closed until you finish it.`,
Examples: []bonfire.Example{
{Command: "make:admin-controller acme.blog Posts", Description: "A Posts controller in acme.blog"},
{Command: "make:admin-controller Posts", Description: "The same from inside plugins/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: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"},
{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 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
},
}
}