feat(11-02): run plugin schedules as River periodic jobs through bonfire.Call
- pact.HasSchedule with ScheduledCommand and Daily/DailyAt/Every cadences (no River import) - bonfire.Call, Catalog and ErrUnknownCommand for in-process command runs - conga Daily/Every wall-clock schedules in app.timezone, periodic jobs on every worker, scheduled queue (MaxAttempts 1, unique by args within the cadence period) - scheduled worker runs only entries matching the compiled table; unregistered commands are skipped with a Warn log - generated app main publishes bonfire.NewCatalog(commands); hello main regenerated
This commit is contained in:
@@ -18,6 +18,7 @@ bonfire is the console layer of SummerCMS. Plugins and framework modules describ
|
||||
- Widgets: box-drawn tables (`bonfire.Output.Table`), a spinner around a function (`bonfire.Output.Spinner`) and a progress bar (`bonfire.Output.Progress`); both fall back to plain lines when output is not a terminal.
|
||||
- Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`, which reads a hidden value on a terminal. Prompts return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking when the session is not interactive.
|
||||
- Injectable streams (`bonfire.NewRootIO`, `bonfire.NewOutput`) so commands can be tested against buffers.
|
||||
- In-process calls: `bonfire.Call` runs a named command with arguments against any writer (Laravel `Artisan::call`), and `bonfire.Catalog` holds an application binary's final command list so code outside the Cobra root, such as the conga scheduler, can call any registered command.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -63,6 +64,18 @@ func main() {
|
||||
}
|
||||
```
|
||||
|
||||
Running a command in-process, with empty stdin so prompts take their defaults:
|
||||
|
||||
```go
|
||||
catalog := bonfire.NewCatalog(commands)
|
||||
if catalog.Has("blog:import") {
|
||||
err := catalog.Call(ctx, "blog:import", []string{"--dry-run", "https://example.com/feed"}, os.Stdout)
|
||||
if errors.Is(err, bonfire.ErrUnknownCommand) {
|
||||
// not registered in this binary
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
@@ -77,6 +90,10 @@ func main() {
|
||||
| `bonfire.NewRootIO` | `bonfire.NewRoot` with injected stdin, stdout and stderr. |
|
||||
| `bonfire.NewOutput` | Builds a `bonfire.Output` over the given streams, applying the terminal and color policy. |
|
||||
| `bonfire.ErrCommandName` | Returned when a plugin command name is not in `namespace:verb` form. |
|
||||
| `bonfire.Call` | Runs one command of a slice by exact name with arguments, writing output to a writer; stdin is empty. |
|
||||
| `bonfire.ErrUnknownCommand` | Returned by `bonfire.Call` when no command has the name. |
|
||||
| `bonfire.Catalog` | An immutable copy of a binary's command list; the generated app main publishes one on the app. |
|
||||
| `bonfire.NewCatalog` | Builds a `bonfire.Catalog` from a command slice. |
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
72
modules/bonfire/call.go
Normal file
72
modules/bonfire/call.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrUnknownCommand is returned by Call when no command has the given name.
|
||||
var ErrUnknownCommand = errors.New("bonfire: unknown command")
|
||||
|
||||
// Call runs the command named name in-process with args, writing its output
|
||||
// to out. Stdin is empty, so prompts take their defaults. A name that no
|
||||
// command has is ErrUnknownCommand.
|
||||
func Call(ctx context.Context, commands []Command, name string, args []string, out io.Writer) error {
|
||||
cmd, ok := findCommand(commands, name)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %q", ErrUnknownCommand, name)
|
||||
}
|
||||
if out == nil {
|
||||
out = io.Discard
|
||||
}
|
||||
root, err := NewRootIO(name, []Command{cmd}, strings.NewReader(""), out, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root.SetArgs(append([]string{name}, args...))
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return root.ExecuteContext(ctx)
|
||||
}
|
||||
|
||||
// Catalog is the final command list of an application binary. The generated
|
||||
// main publishes it on the app so code outside the cobra root, such as the
|
||||
// scheduler, can run any registered command with Call.
|
||||
type Catalog struct {
|
||||
commands []Command
|
||||
}
|
||||
|
||||
// NewCatalog returns a Catalog holding a copy of commands.
|
||||
func NewCatalog(commands []Command) *Catalog {
|
||||
return &Catalog{commands: append([]Command(nil), commands...)}
|
||||
}
|
||||
|
||||
// Has reports whether the catalog holds a command named name.
|
||||
func (c *Catalog) Has(name string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := findCommand(c.commands, name)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Call runs the named catalog command in-process; see the package Call.
|
||||
func (c *Catalog) Call(ctx context.Context, name string, args []string, out io.Writer) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("%w: %q", ErrUnknownCommand, name)
|
||||
}
|
||||
return Call(ctx, c.commands, name, args, out)
|
||||
}
|
||||
|
||||
func findCommand(commands []Command, name string) (Command, bool) {
|
||||
for _, c := range commands {
|
||||
if c.Name == name {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return Command{}, false
|
||||
}
|
||||
55
modules/bonfire/call_test.go
Normal file
55
modules/bonfire/call_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCall(t *testing.T) {
|
||||
var gotArgs []string
|
||||
var gotFlag string
|
||||
cmds := []Command{{
|
||||
Name: "acme:echo",
|
||||
Flags: []Flag{{Name: "loud", Bare: true}},
|
||||
Args: []Arg{{Name: "word"}},
|
||||
Run: func(ctx context.Context, in Input, out Output) error {
|
||||
gotArgs = in.Args()
|
||||
gotFlag, _ = in.Flag("loud")
|
||||
out.Printf("echo %s\n", strings.Join(in.Args(), " "))
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := Call(t.Context(), cmds, "acme:echo", []string{"--loud", "hello", "world"}, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(gotArgs, ",") != "hello,world" || gotFlag != "true" {
|
||||
t.Fatalf("args = %v, loud = %q", gotArgs, gotFlag)
|
||||
}
|
||||
if out.String() != "echo hello world\n" {
|
||||
t.Fatalf("out = %q", out.String())
|
||||
}
|
||||
|
||||
err := Call(t.Context(), cmds, "acme:missing", nil, &out)
|
||||
if !errors.Is(err, ErrUnknownCommand) || !strings.Contains(err.Error(), `"acme:missing"`) {
|
||||
t.Fatalf("err = %v, want ErrUnknownCommand naming the command", err)
|
||||
}
|
||||
|
||||
cat := NewCatalog(cmds)
|
||||
cmds[0].Name = "acme:renamed"
|
||||
if !cat.Has("acme:echo") || cat.Has("acme:renamed") {
|
||||
t.Fatal("catalog does not hold a copy of the commands")
|
||||
}
|
||||
out.Reset()
|
||||
if err := cat.Call(t.Context(), "acme:echo", []string{"x"}, &out); err != nil || out.String() != "echo x\n" {
|
||||
t.Fatalf("catalog call: out %q, err %v", out.String(), err)
|
||||
}
|
||||
var nilCat *Catalog
|
||||
if nilCat.Has("acme:echo") || !errors.Is(nilCat.Call(t.Context(), "acme:echo", nil, &out), ErrUnknownCommand) {
|
||||
t.Fatal("nil catalog must hold no commands")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user