- 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
221 lines
9.4 KiB
Go
221 lines
9.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/internal/build"
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
)
|
|
|
|
// appRunner runs the app binary with argv; tests replace it.
|
|
var appRunner = runAppBinary
|
|
|
|
// delegateCommands returns every command summer runs on the app binary. One
|
|
// table is the single source: name, base description, a hint naming the
|
|
// common flags or arguments the app binary accepts, and two examples.
|
|
func delegateCommands() []bonfire.Command {
|
|
ex := func(cmd, desc string) bonfire.Example { return bonfire.Example{Command: cmd, Description: desc} }
|
|
rows := []struct {
|
|
name, description, hint string
|
|
examples []bonfire.Example
|
|
}{
|
|
{"migrate", "Run plugin migrations in the app binary", "--pretend, --force", []bonfire.Example{
|
|
ex("migrate", "Apply every pending migration"),
|
|
ex("migrate --pretend", "List the pending migrations without applying them")}},
|
|
{"migrate:rollback", "Roll back the last migration of a plugin in the app binary", "--plugin <id>, --pretend, --force", []bonfire.Example{
|
|
ex("migrate:rollback --plugin acme.blog", "Roll back the last migration of acme.blog"),
|
|
ex("migrate:rollback --plugin acme.blog --pretend", "Name the migration that would roll back")}},
|
|
{"migrate:status", "Show per-plugin migration history in the app binary", "--json", []bonfire.Example{
|
|
ex("migrate:status", "Show the applied migrations per plugin"),
|
|
ex("migrate:status --json", "Machine-readable status")}},
|
|
{"serve", "Run the app HTTP server", "--addr <host:port> (default :8080)", []bonfire.Example{
|
|
ex("serve", "Serve on port 8080"),
|
|
ex("serve --addr 127.0.0.1:8123", "Serve on loopback port 8123")}},
|
|
{"queue:work", "Run background job workers in the app binary", "--queue <name> (repeatable)", []bonfire.Example{
|
|
ex("queue:work", "Work every known queue"),
|
|
ex("queue:work --queue mail", "Work only the mail queue")}},
|
|
{"queue:clear", "Clear pending queued jobs in the app binary", `[queue] argument (default "default"), --force`, []bonfire.Example{
|
|
ex("queue:clear", "Clear the default queue"),
|
|
ex("queue:clear imports --force", "Clear the imports queue without the production question")}},
|
|
{"schedule:run", "Run the scheduler in the app binary (or once for system cron)", "--once", []bonfire.Example{
|
|
ex("schedule:run", "Run the scheduler in the foreground"),
|
|
ex("schedule:run --once", "Run the entries due now and exit")}},
|
|
{"route:list", "List the app's routes from the app binary", "--json", []bonfire.Example{
|
|
ex("route:list", "Show every route"),
|
|
ex("route:list --json", "Machine-readable route list")}},
|
|
{"key:generate", "Print a fresh application key from the app binary", "--json", []bonfire.Example{
|
|
ex("key:generate", "Print a new key"),
|
|
ex("key:generate -q", "Print only the key")}},
|
|
{"admin:create", "Create a backend administrator in the app binary", "--email <email> (required), --login, --role <code>, --superuser; the password is prompted or read from stdin", []bonfire.Example{
|
|
ex("admin:create --email admin@example.com --superuser", "Create a superuser; the password is asked for"),
|
|
ex("admin:create --email editor@example.com --role editor", "Create an administrator with a role")}},
|
|
{"admin:reset-password", "Reset a backend administrator's password in the app binary", "<identifier> (login or email); the password is prompted or read from stdin", []bonfire.Example{
|
|
ex("admin:reset-password admin@example.com", "Reset a password; it is asked for"),
|
|
ex("admin:reset-password admin", "Reset by login")}},
|
|
{"admin:token:create", "Create a personal API token for a backend administrator in the app binary", "<identifier> --name <text> --scope <name> (repeatable) [--expires 30d|90d|365d|never|YYYY-MM-DD] [--json]", []bonfire.Example{
|
|
ex("admin:token:create jane --name agent --scope journal.posts", "A token limited to one scope"),
|
|
ex("admin:token:create jane --scope journal.posts --json", "The token and its details as JSON")}},
|
|
{"admin:token:list", "List personal API tokens in the app binary", "[<identifier>] [--revoked] [--json]", []bonfire.Example{
|
|
ex("admin:token:list", "Every active token"),
|
|
ex("admin:token:list jane --revoked", "Every token of one administrator")}},
|
|
{"admin:token:revoke", "Revoke a personal API token by id in the app binary", "<id>", []bonfire.Example{
|
|
ex("admin:token:revoke 12", "Revoke token 12"),
|
|
ex("admin:token:revoke 7 -q", "Revoke token 7 and print nothing but errors")}},
|
|
{"admin:token:scopes", "List available personal API token scopes in the app binary", "--json", []bonfire.Example{
|
|
ex("admin:token:scopes", "Show every scope"),
|
|
ex("admin:token:scopes --json", "Machine-readable scope list")}},
|
|
}
|
|
cmds := make([]bonfire.Command, 0, len(rows))
|
|
for _, r := range rows {
|
|
c := delegateCommand(r.name, r.description, r.hint)
|
|
c.Examples = r.examples
|
|
cmds = append(cmds, c)
|
|
}
|
|
return cmds
|
|
}
|
|
|
|
// delegateCommand returns a passthrough command that runs name on the app
|
|
// binary with every argument summer was given.
|
|
func delegateCommand(name, description, hint string) bonfire.Command {
|
|
long := description + ".\n\nsummer runs bin/<binary> " + name +
|
|
" in the application directory, building the binary first when it is missing. Every flag and argument after " +
|
|
name + ", including --help, is passed to the app binary unchanged, and so are the global options (-v, -q, --ansi, --no-ansi, -n, --env). " +
|
|
"summer fails when the app binary fails."
|
|
if hint != "" {
|
|
long += "\n\nCommon flags: " + hint + "."
|
|
}
|
|
long += "\n\nRun summer " + name + " --help inside an application to list all of them."
|
|
return bonfire.Command{
|
|
Name: name,
|
|
Description: description + " (flags pass to the app binary)",
|
|
Long: long,
|
|
Passthrough: true,
|
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
|
args := in.Args()
|
|
// One leading "--" is dropped so the older
|
|
// `summer serve -- --addr :8123` form keeps working.
|
|
if len(args) > 0 && args[0] == "--" {
|
|
args = args[1:]
|
|
}
|
|
argv := append([]string{name}, args...)
|
|
return appRunner(ctx, argv, out)
|
|
},
|
|
}
|
|
}
|
|
|
|
func runAppBinary(ctx context.Context, argv []string, out bonfire.Output) error {
|
|
if len(argv) == 0 {
|
|
return fmt.Errorf("summer: missing app command")
|
|
}
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
appDir, err := build.FindAppDir(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m, err := build.LoadManifest(filepath.Join(appDir, "summer.yaml"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bin := filepath.Join(appDir, "bin", m.Binary)
|
|
if _, err := os.Stat(bin); err != nil {
|
|
if err := build.App(ctx, appDir, out); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
self, err := os.Executable()
|
|
if err == nil {
|
|
if same, _ := sameFile(self, bin); same {
|
|
return fmt.Errorf("summer: refusing to recurse into %s", bin)
|
|
}
|
|
}
|
|
cmd := newAppCmd(ctx, bin, appDir, argv, out)
|
|
sigs := make(chan os.Signal, 2)
|
|
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
|
|
defer signal.Stop(sigs)
|
|
if err := runChild(cmd, sigs); err != nil {
|
|
return fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// appShutdownGrace is how long a cancelled app binary may take to exit after
|
|
// SIGTERM before it is killed. It is longer than the 10-second shutdown
|
|
// timeout of serve and the workers, so they finish a graceful shutdown.
|
|
const appShutdownGrace = 15 * time.Second
|
|
|
|
// newAppCmd builds the command that runs the app binary bin with argv in dir.
|
|
// The child inherits summer's stdin, so the hidden password prompts of
|
|
// admin:create and admin:reset-password read from the terminal. Cancelling
|
|
// ctx sends SIGTERM, and the child is killed only after appShutdownGrace.
|
|
func newAppCmd(ctx context.Context, bin, dir string, argv []string, out io.Writer) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, bin, argv...)
|
|
cmd.Dir = dir
|
|
cmd.Stdout = out
|
|
cmd.Stderr = out
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Env = os.Environ()
|
|
cmd.Cancel = func() error {
|
|
err := cmd.Process.Signal(syscall.SIGTERM)
|
|
if err != nil && !errors.Is(err, os.ErrProcessDone) {
|
|
// SIGTERM is not supported everywhere (Windows); kill instead.
|
|
return cmd.Process.Kill()
|
|
}
|
|
return err
|
|
}
|
|
cmd.WaitDelay = appShutdownGrace
|
|
return cmd
|
|
}
|
|
|
|
// runChild starts cmd, forwards every signal received on sigs to it and
|
|
// waits for it to exit, returning Wait's error. It never escalates to a kill:
|
|
// the app binary decides how to shut down.
|
|
//
|
|
// Ctrl+C in a terminal delivers SIGINT to both summer and the child, because
|
|
// they share the foreground process group, so the child may see SIGINT twice.
|
|
// The app's NotifyContext-based shutdown tolerates that. The child is
|
|
// deliberately not put in its own process group (Setpgid): a background
|
|
// process group that reads the terminal gets SIGTTIN, which would break the
|
|
// password prompts of admin:create and admin:reset-password.
|
|
func runChild(cmd *exec.Cmd, sigs <-chan os.Signal) error {
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
for {
|
|
select {
|
|
case err := <-done:
|
|
return err
|
|
case sig := <-sigs:
|
|
// Best effort: the child may already have exited
|
|
// (os.ErrProcessDone) or the platform may not support sig.
|
|
_ = cmd.Process.Signal(sig)
|
|
}
|
|
}
|
|
}
|
|
|
|
func sameFile(a, b string) (bool, error) {
|
|
ai, err := os.Stat(a)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
bi, err := os.Stat(b)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return os.SameFile(ai, bi), nil
|
|
}
|