feat: Artisan checklist for application commands, hints and coverage gate
- migrate, migrate:rollback and deferred:purge gain --pretend and --force; they and queue:clear confirm only in production on an interactive terminal, so deploys over SSH proceed as before - result lines: Applied N migrations, Rolled back <id> of <plugin>, Cleared N jobs from <queue> - --json for migrate:status, key:generate, route:list, admin:token:create, admin:token:list, admin:token:scopes and websockets:health; -q keeps the key and the token - hints for an empty DSN, an unreachable database, an invalid app.key, a missing uploads bucket and an application binary run below its root - flare and websockets:health return bonfire.Reported after printing - long help and examples for every application command, greeter:hello and blog:publish; cmd/summer TestCommandsDocumented fails on any gap
This commit is contained in:
41
cmd/summer/commands_documented_test.go
Normal file
41
cmd/summer/commands_documented_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/flare"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
// applicationCommands is every command the framework gives an application
|
||||
// binary, built the way docsCommands does. The blog example's blog:publish
|
||||
// is checked by its own package's test: the tool must not import example
|
||||
// plugins (TestToolDoesNotImportExamplePlugins).
|
||||
func applicationCommands() []bonfire.Command {
|
||||
app := backpack.New(&compass.Config{})
|
||||
var cmds []bonfire.Command
|
||||
cmds = append(cmds, lagoon.RuntimeCommands(app, nil)...)
|
||||
cmds = append(cmds, conga.RuntimeCommands(app, nil)...)
|
||||
cmds = append(cmds, surf.ServeCommand(app, nil), surf.RouteListCommand(app, nil))
|
||||
cmds = append(cmds, cabana.RuntimeCommands(app)...)
|
||||
cmds = append(cmds, centrifugo.Commands(app)...)
|
||||
cmds = append(cmds, flare.Commands(app)...)
|
||||
return cmds
|
||||
}
|
||||
|
||||
// TestCommandsDocumented is the framework's documentation coverage gate
|
||||
// (D-17): every framework command has a description, long help, examples
|
||||
// and described arguments and options, and the test fails on any gap.
|
||||
func TestCommandsDocumented(t *testing.T) {
|
||||
commands := applicationCommands()
|
||||
for _, problem := range bonfire.CheckDocs(commands) {
|
||||
t.Error(problem)
|
||||
}
|
||||
}
|
||||
@@ -12,28 +12,39 @@ Every application binary carries the framework's runtime commands. The examples
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `migrate` | none | Runs the framework migrations, then every plugin's migrations in dependency order. |
|
||||
| `migrate:rollback` | `--plugin <id>` | Rolls back the last migration of the plugin; without the flag, of the last activated plugin that has migrations. |
|
||||
| `migrate:status` | none | Prints a table of plugin, history table and applied migration IDs. |
|
||||
| `migrate` | `--pretend`, `--force` | Runs the framework migrations, then every plugin's migrations in dependency order, printing how many each set applied and `Applied N migrations` (or `Nothing to migrate`). `--pretend` lists the pending migrations as `<plugin>: <id>` and applies nothing. |
|
||||
| `migrate:rollback` | `--plugin <id>`, `--pretend`, `--force` | Rolls back the last migration of the plugin; without the flag, of the last activated plugin that has migrations. Prints `Rolled back <id> of <plugin>`; `--pretend` only names it. |
|
||||
| `migrate:status` | `--json` | Prints a table of plugin, history table and applied migration IDs. `--json` prints every declared migration of those plugins with `plugin`, `table`, `id` and `applied`. |
|
||||
|
||||
```sh
|
||||
./bin/acme migrate --pretend
|
||||
./bin/acme migrate
|
||||
./bin/acme migrate:status
|
||||
./bin/acme migrate:status --json
|
||||
./bin/acme migrate:rollback --plugin acme.blog
|
||||
```
|
||||
|
||||
Each plugin keeps its own migration history table, so rolling back one plugin never touches another. The migrations are listed in [lagoon](../../modules/lagoon/README.md).
|
||||
|
||||
### Confirmation in production
|
||||
|
||||
`migrate`, `migrate:rollback`, `deferred:purge` and `queue:clear` change or delete data. When the environment is `production` and the command runs on an interactive terminal, they ask before doing it; answering no prints a cancellation and exits 1. `--force` skips the question. Off a terminal (a deploy over SSH, a container entrypoint, the scheduler) and with `-n` they never ask and proceed as before, so deploy scripts need no change.
|
||||
|
||||
```sh
|
||||
./bin/acme migrate --force
|
||||
```
|
||||
|
||||
When the database cannot be reached, or `database.dsn` is empty, the error names the fix (`docker compose up -d --wait`, `cp .env.example .env`). Run from a directory below the application root, an application binary finds no `config/` and adds a hint to its error with the command to run instead, for example `cd ../..; bin/acme migrate`.
|
||||
|
||||
## Pending uploads and related records
|
||||
|
||||
An admin form holds the files uploaded and the related records created for a record that is not saved yet as deferred bindings. Saving the record commits them; abandoned ones are removed by `deferred:purge`:
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `deferred:purge` | `--days <n>` | Deletes deferred bindings older than `n` days, the unattached uploads they hold and the records created under deferral, and prints the counts. Records that were only linked are kept. Without the flag, `database.deferred_bindings.purge_days` applies, else 5 days. |
|
||||
| `deferred:purge` | `--days <n>`, `--pretend`, `--force` | Deletes deferred bindings older than `n` days, the unattached uploads they hold and the records created under deferral, and prints the counts. Records that were only linked are kept. Without the flag, `database.deferred_bindings.purge_days` applies, else 5 days. `--pretend` counts the bindings that would go and deletes nothing. |
|
||||
|
||||
```sh
|
||||
./bin/acme deferred:purge
|
||||
./bin/acme deferred:purge --pretend
|
||||
./bin/acme deferred:purge --days=1
|
||||
```
|
||||
|
||||
@@ -43,10 +54,11 @@ The scheduler already runs it every day at 03:00; see [Task scheduling](../plugi
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `key:generate` | none | Prints a fresh base64 32-byte key for `app.key`. It writes nothing and needs no database. |
|
||||
| `key:generate` | `--json` | Prints a fresh base64 32-byte key for `app.key`. It writes nothing and needs no database. With `-q` it prints only the key; `--json` prints `{"key": "..."}`. |
|
||||
|
||||
```sh
|
||||
./bin/acme key:generate
|
||||
KEY=$(./bin/acme key:generate -q)
|
||||
```
|
||||
|
||||
Copy the printed value into `SUMMER_APP__KEY`. When you rotate the key, keep the old one in `app.previous_keys` so existing encrypted columns can still be read.
|
||||
@@ -56,7 +68,7 @@ Copy the printed value into `SUMMER_APP__KEY`. When you rotate the key, keep the
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, builds the router, binds the address, starts the in-process job worker and serves HTTP until SIGINT or SIGTERM. Once it listens it prints a startup summary; on a signal it drains requests and the worker within 10 seconds and prints a shutdown report. |
|
||||
| `route:list` | none | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag, followed by the admin status line. |
|
||||
| `route:list` | `--json` | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag, followed by the admin status line. `--json` prints the routes as a JSON array. |
|
||||
|
||||
```sh
|
||||
./bin/acme route:list
|
||||
@@ -111,10 +123,10 @@ Jobs still running at the deadline are cancelled; River records the cancelled at
|
||||
|---------|---------------------|---------|
|
||||
| `admin:create` | `--email` (required), `--login`, `--role <code>`, `--superuser`, `--password` (deprecated) | Creates an activated backend administrator. `--login` defaults to the lower-cased email. The password is read from a hidden prompt, or from stdin when the input is not a terminal. |
|
||||
| `admin:reset-password` | `<identifier>` (login or email), `--password` (deprecated) | Sets a new password, read like the one of `admin:create`, and revokes every JWT issued before the reset. Personal API tokens are unchanged. |
|
||||
| `admin:token:create` | `<identifier>`, `--name`, `--scope` (repeatable), `--expires` (`30d`, `90d`, `365d`, `never`, or `YYYY-MM-DD`) | Creates a personal API token for the named administrator and prints exactly the plaintext and a newline on stdout. |
|
||||
| `admin:token:list` | `[<identifier>]`, `--revoked` | Lists tokens (hint only, never the hash or plaintext). |
|
||||
| `admin:token:create` | `<identifier>`, `--name`, `--scope` (repeatable), `--expires` (`30d`, `90d`, `365d`, `never`, or `YYYY-MM-DD`), `--json` | Creates a personal API token for the named administrator and prints exactly the plaintext and a newline on stdout, also under `-q`. `--json` prints `id`, `name`, `token`, `scopes` and `expires_at`. |
|
||||
| `admin:token:list` | `[<identifier>]`, `--revoked`, `--json` | Lists tokens (hint only, never the hash or plaintext); `--json` as a JSON array. |
|
||||
| `admin:token:revoke` | `<id>` | Soft-revokes any user's active token by id. |
|
||||
| `admin:token:scopes` | none | Lists framework and plugin scopes available for tokens. |
|
||||
| `admin:token:scopes` | `--json` | Lists framework and plugin scopes available for tokens; `--json` as a JSON array. |
|
||||
|
||||
```sh
|
||||
./bin/acme admin:create --email admin@example.com --superuser
|
||||
@@ -133,7 +145,7 @@ When the admin is mounted and the `backend_users` table is empty, `serve` prints
|
||||
| Command | Arguments and flags | Purpose |
|
||||
|---------|---------------------|---------|
|
||||
| `queue:work` | `--queue <name>`, repeatable | Runs a job worker in the foreground on the named queues (default: every known queue) until SIGINT or SIGTERM, with a startup summary and a shutdown report. An unknown queue is an error that lists the known ones. |
|
||||
| `queue:clear` | `[queue]` (default `default`) | Deletes the waiting, scheduled and retryable jobs of one queue and prints how many it cleared. Running jobs are never touched. |
|
||||
| `queue:clear` | `[queue]` (default `default`), `--force` | Deletes the waiting, scheduled and retryable jobs of one queue and prints `Cleared N jobs from <queue>`. Running jobs are never touched. |
|
||||
| `schedule:run` | `--once` | Without `--once`, runs a scheduler-only worker until stopped, with a startup summary and a shutdown report. With `--once`, runs the entries due in the current minute, prints `Ran N due entries (M failed)` and exits, for system cron. |
|
||||
|
||||
```sh
|
||||
@@ -152,9 +164,9 @@ These commands are not added by the generated `main`. An application that uses C
|
||||
|
||||
| Command | Arguments and flags | Purpose |
|
||||
|---------|---------------------|---------|
|
||||
| `websockets:health` | none | Calls the Centrifugo `info` API and prints the configuration. Exits 1 when the API key is missing or the call fails. The key itself is never printed. |
|
||||
| `websockets:health` | `--json` | Calls the Centrifugo `info` API and prints the configuration. Exits 1 when the API key is missing or the call fails. The key itself is never printed. `--json` prints `healthy`, `api_url`, `enabled`, `api_key_set` and `error` for monitoring. |
|
||||
| `websockets:generate-vapid-keys` | `--update`, `--show-current` | Shows the configured VAPID keys, truncated, then generates a new pair. With `--update` it saves them to the environment's `overrides.yaml`; without it, it prints the variables to set by hand. |
|
||||
| `websockets:test-push` | `<user_id>`, `--show-config` | Lists a user's push subscriptions and, after confirmation, sends one encrypted test notification to each. |
|
||||
| `websockets:test-push` | `<user_id>`, `--show-config` | Lists a user's push subscriptions and, after confirmation (yes by default, so `-n` sends without asking), sends one encrypted test notification to each. |
|
||||
|
||||
```sh
|
||||
./bin/acme websockets:health
|
||||
|
||||
@@ -179,6 +179,19 @@ fmt.Println("exit code:", code)
|
||||
|
||||
A command that has already printed its problems, such as a checker that lists every finding, returns `bonfire.Reported(err)`: the binary exits 1 and prints nothing more. A mistake in how the command was called, such as an unknown flag or a missing argument, exits 2 and the block suggests `help <command>`; wrap your own usage checks with `bonfire.Usage` to get the same exit code.
|
||||
|
||||
An application binary also explains the most common mistake: running it from the wrong directory. When a command fails, the working directory has no `config/` directory and a parent directory holds `summer.yaml`, the block says "This command runs from the application root." and suggests the same command from there:
|
||||
|
||||
```text
|
||||
ERROR lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)
|
||||
|
||||
This command runs from the application root.
|
||||
|
||||
Try:
|
||||
cd ../..; bin/acme migrate
|
||||
```
|
||||
|
||||
Only the message changes: the binary still reads its configuration from `config/` in the working directory. `summer` is not affected, because its commands find the application root themselves.
|
||||
|
||||
## Documentation coverage
|
||||
|
||||
`bonfire.CheckDocs` reports each command that lacks a description, long help, an example, or a description of one of its arguments or options. It only reports; the framework's own test fails on any problem, and a plugin repository can call it from a test and log the problems:
|
||||
|
||||
@@ -17,7 +17,12 @@ func PublishCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) er
|
||||
return bonfire.Command{
|
||||
Name: "blog:publish",
|
||||
Description: "Publish a blog post by its slug",
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||
Long: `Sets published_at on the post with this slug. Publishing a post twice keeps
|
||||
the first publication time. It fails when no post has the slug.`,
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "blog:publish hello-world", Description: "Publish the post hello-world"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
slug, _ := in.Argument("slug")
|
||||
if slug == "" {
|
||||
|
||||
@@ -27,6 +27,9 @@ func TestPublishCommandShape(t *testing.T) {
|
||||
if cmd.Description == "" {
|
||||
t.Error("Description is empty")
|
||||
}
|
||||
for _, problem := range bonfire.CheckDocs([]bonfire.Command{cmd}) {
|
||||
t.Error(problem)
|
||||
}
|
||||
|
||||
cmds := []bonfire.Command{cmd}
|
||||
var out bytes.Buffer
|
||||
|
||||
@@ -748,7 +748,12 @@ func PublishCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) er
|
||||
return bonfire.Command{
|
||||
Name: "blog:publish",
|
||||
Description: "Publish a blog post by its slug",
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||
Long: `Sets published_at on the post with this slug. Publishing a post twice keeps
|
||||
the first publication time. It fails when no post has the slug.`,
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "blog:publish hello-world", Description: "Publish the post hello-world"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
slug, _ := in.Argument("slug")
|
||||
if slug == "" {
|
||||
|
||||
@@ -337,3 +337,28 @@ func generatedHash(t *testing.T) [32]byte {
|
||||
}
|
||||
return sha256.Sum256(buf.Bytes())
|
||||
}
|
||||
|
||||
// TestGreeterDocumented: the greeter plugin's commands carry a description,
|
||||
// long help, examples and flag descriptions (bonfire.CheckDocs).
|
||||
func TestGreeterDocumented(t *testing.T) {
|
||||
cfg, err := compass.Load("config")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
plugins, err := party.Activate(backpack.New(cfg), []string{"golem15.hello", "golem15.greeter"})
|
||||
if err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
var commands []bonfire.Command
|
||||
for _, p := range plugins {
|
||||
if hc, ok := p.(pact.HasCommands); ok && p.ID() == "golem15.greeter" {
|
||||
commands = append(commands, hc.Commands()...)
|
||||
}
|
||||
}
|
||||
if len(commands) == 0 {
|
||||
t.Fatal("the greeter plugin has no commands")
|
||||
}
|
||||
for _, problem := range bonfire.CheckDocs(commands) {
|
||||
t.Error(problem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,14 @@ func (p *Plugin) Commands() []bonfire.Command {
|
||||
return []bonfire.Command{{
|
||||
Name: "greeter:hello",
|
||||
Description: "Print the configured application name",
|
||||
Long: `Prints app.name, golem15.hello.posts_per_page and app.debug from the layered
|
||||
configuration, then fires, collects and hands a HelloEvent to the event bus
|
||||
and reports what came back. The example application's tests use it to check
|
||||
configuration layering and events end to end.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "greeter:hello", Description: "Print the configured values"},
|
||||
{Command: "greeter:hello --env production", Description: "Print the values of the production configuration"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
name := ""
|
||||
posts := 0
|
||||
|
||||
@@ -46,7 +46,7 @@ Use cases:
|
||||
- Hidden commands (`bonfire.Command.Hidden`) run but stay out of the list unless `list --all`.
|
||||
- JSON catalog: `list --format=json` prints every command, hidden ones flagged, with arguments, options, defaults and examples as a `bonfire.Listing` (`bonfire.Describe` builds it from a command slice).
|
||||
- Documentation coverage: `bonfire.CheckDocs` reports commands lacking a description, long help, examples, or argument and option descriptions.
|
||||
- Error block and exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print on the error stream as a block (`bonfire.RenderError`): the ERROR badge and the message, the explanation and Try lines of `bonfire.WithHint` (`{bin}` becomes `bin/<name>` or `summer`), cobra's "did you mean" suggestions as Try lines, and "Run with -v for details." or, at `-v`, the wrapped error chain; every message passes through `sunscreen.Scrub`. Errors wrapped by `bonfire.Reported` print nothing. `bonfire.Fail` prints errors that happen before the root exists.
|
||||
- Error block and exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print on the error stream as a block (`bonfire.RenderError`): the ERROR badge and the message, the explanation and Try lines of `bonfire.WithHint` (`{bin}` becomes `bin/<name>` or `summer`), cobra's "did you mean" suggestions as Try lines, and "Run with -v for details." or, at `-v`, the wrapped error chain; every message passes through `sunscreen.Scrub`. Errors wrapped by `bonfire.Reported` print nothing. `bonfire.Fail` prints errors that happen before the root exists. For an application binary (any root other than `summer`) that fails, other than with a usage error, while the working directory has no `config/` directory and a parent directory holds `summer.yaml`, both add the hint "This command runs from the application root." with `cd <relative path>; bin/<name> <args>` (only the message changes).
|
||||
- Name checks at boot: two commands with the same name, or a command named `list` or `help`, make `bonfire.NewRoot` fail with `bonfire.ErrDuplicateCommand`; a command flag that reuses a global option's name or shorthand fails with `bonfire.ErrReservedFlag` instead of panicking.
|
||||
- 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.
|
||||
@@ -191,7 +191,7 @@ Without these variables, color is enabled only when stdout is a terminal. `--ans
|
||||
|
||||
- SummerCMS modules: `sunscreen` (scrubs error messages).
|
||||
- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2` (with `lipgloss/v2/table`), `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (spinner, progress, key), `charm.land/huh/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
|
||||
- Standard library: `bufio`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
|
||||
- Standard library: `bufio`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `path/filepath`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package bonfire
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/sunscreen"
|
||||
@@ -173,6 +175,63 @@ func RenderError(w io.Writer, name string, err error, g Globals) {
|
||||
_ = writeStyled(w, g, b.String())
|
||||
}
|
||||
|
||||
// wrongDirectoryExplanation is the hint added when an application binary
|
||||
// fails outside its application root.
|
||||
const wrongDirectoryExplanation = "This command runs from the application root."
|
||||
|
||||
// wrongDirectory adds a hint to err when an application binary (any root
|
||||
// name other than summer) failed while the working directory dir ("" means
|
||||
// os.Getwd) has no config directory and a parent directory holds
|
||||
// summer.yaml: compass then found no configuration, which is why the command
|
||||
// failed (D-19). The Try line is "cd <relative path>; bin/<name> <args>".
|
||||
// Usage errors, Reported errors and summer are left alone; nothing else
|
||||
// about the failure changes.
|
||||
func wrongDirectory(name string, err error, args []string, dir string) error {
|
||||
if err == nil || name == "summer" || errors.Is(err, ErrReported) || isUsage(err) {
|
||||
return err
|
||||
}
|
||||
if dir == "" {
|
||||
wd, werr := os.Getwd()
|
||||
if werr != nil {
|
||||
return err
|
||||
}
|
||||
dir = wd
|
||||
}
|
||||
if info, serr := os.Stat(filepath.Join(dir, "config")); serr == nil && info.IsDir() {
|
||||
return err
|
||||
}
|
||||
root := ""
|
||||
for d := filepath.Dir(dir); ; d = filepath.Dir(d) {
|
||||
if _, serr := os.Stat(filepath.Join(d, "summer.yaml")); serr == nil {
|
||||
root = d
|
||||
break
|
||||
}
|
||||
if filepath.Dir(d) == d {
|
||||
break
|
||||
}
|
||||
}
|
||||
if root == "" {
|
||||
return err
|
||||
}
|
||||
rel, rerr := filepath.Rel(dir, root)
|
||||
if rerr != nil {
|
||||
return err
|
||||
}
|
||||
try := "cd " + shellQuote(rel) + "; {bin}"
|
||||
for _, a := range args {
|
||||
try += " " + shellQuote(a)
|
||||
}
|
||||
return &HintError{Err: err, Explanation: wrongDirectoryExplanation, Try: try}
|
||||
}
|
||||
|
||||
// shellQuote single-quotes s when a shell would split or expand it.
|
||||
func shellQuote(s string) string {
|
||||
if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$`*?[]{}()<>|&;#~!") {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// parseUnknownCommand reads the command name and cobra's "Did you mean
|
||||
// this?" suggestions from an unknown command error.
|
||||
func parseUnknownCommand(msg string) (string, []string) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package bonfire
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -52,7 +53,7 @@ func Execute(ctx context.Context, root *cobra.Command, args []string) int {
|
||||
// fang hands over a colorprofile writer built from the
|
||||
// environment only; RenderError unwraps it and applies
|
||||
// --ansi and --no-ansi itself.
|
||||
RenderError(w, name, err, g)
|
||||
RenderError(w, name, wrongDirectory(name, err, args, ""), g)
|
||||
}),
|
||||
)
|
||||
return exitCode(err)
|
||||
@@ -60,12 +61,14 @@ func Execute(ctx context.Context, root *cobra.Command, args []string) int {
|
||||
|
||||
// Fail renders err like Execute does and returns ExitFailure (ExitOK for a
|
||||
// nil error). The generated main uses it for errors that happen before the
|
||||
// command root exists, such as a configuration that does not load.
|
||||
// command root exists, such as a configuration that does not load. Like
|
||||
// Execute, it adds the wrong-directory hint for an application binary run
|
||||
// outside its application root.
|
||||
func Fail(w io.Writer, name string, err error, g Globals) int {
|
||||
if err == nil {
|
||||
return ExitOK
|
||||
}
|
||||
RenderError(w, name, err, g)
|
||||
RenderError(w, name, wrongDirectory(name, err, os.Args[1:], ""), g)
|
||||
return ExitFailure
|
||||
}
|
||||
|
||||
|
||||
57
modules/bonfire/wrongdir_smoke_test.go
Normal file
57
modules/bonfire/wrongdir_smoke_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWrongDirectoryHintSmoke: an application binary failing below its
|
||||
// application root (no config/ here, summer.yaml in a parent) gets the
|
||||
// "cd <rel>; bin/<name> <args>" hint; summer, a directory with config/ and
|
||||
// a usage error do not.
|
||||
func TestWrongDirectoryHintSmoke(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "summer.yaml"), []byte("binary: acme\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := filepath.Join(root, "plugins", "acme")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "config"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fail := errors.New("lagoon: database.dsn is empty")
|
||||
|
||||
got := wrongDirectory("acme", fail, []string{"migrate", "--plugin", "acme blog"}, sub)
|
||||
var hint *HintError
|
||||
if !errors.As(got, &hint) || hint.Explanation != wrongDirectoryExplanation ||
|
||||
hint.Try != "cd ../..; {bin} migrate --plugin 'acme blog'" || !errors.Is(got, fail) {
|
||||
t.Fatalf("hint = %#v", got)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
RenderError(&buf, "acme", got, Globals{Color: ColorNever})
|
||||
if !strings.Contains(buf.String(), "cd ../..; bin/acme migrate --plugin 'acme blog'") {
|
||||
t.Fatalf("rendered block lacks the Try line:\n%s", buf.String())
|
||||
}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
name string
|
||||
err error
|
||||
dir string
|
||||
}{
|
||||
"summer": {"summer", fail, sub},
|
||||
"app root": {"acme", fail, root},
|
||||
"usage error": {"acme", Usage(fail), sub},
|
||||
"reported": {"acme", Reported(fail), sub},
|
||||
"no app above": {"acme", fail, t.TempDir()},
|
||||
} {
|
||||
if got := wrongDirectory(tc.name, tc.err, []string{"migrate"}, tc.dir); errors.As(got, &hint) && hint.Explanation == wrongDirectoryExplanation {
|
||||
t.Errorf("%s: unexpected hint %#v", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -355,10 +355,10 @@ These commands are added to every application binary by the generated `main` and
|
||||
|---------|---------------------|--------|
|
||||
| `admin:create` | `--email` (required), `--login` (defaults to the lower-cased email), `--role <code>`, `--superuser`, `--password` (deprecated) | Creates an activated backend administrator. The password is read from a hidden prompt, or from stdin when it is not a terminal. |
|
||||
| `admin:reset-password` | `<identifier>` (login or email), `--password` (deprecated) | Sets a new password, read like the one of `admin:create`, and revokes every JWT issued before the reset. Personal API tokens are unchanged. |
|
||||
| `admin:token:create` | `<identifier>`, `--name`, `--scope` (repeatable), `--expires` (`30d`, `90d`, `365d`, `never`, or `YYYY-MM-DD`) | Creates a personal API token for the named administrator and prints exactly the plaintext and a newline on stdout. |
|
||||
| `admin:token:list` | `[<identifier>]`, `--revoked` | Lists tokens (hint only, never the hash or plaintext). |
|
||||
| `admin:token:create` | `<identifier>`, `--name`, `--scope` (repeatable), `--expires` (`30d`, `90d`, `365d`, `never`, or `YYYY-MM-DD`), `--json` | Creates a personal API token for the named administrator and prints exactly the plaintext and a newline on stdout, under `-q` too. `--json` prints `id`, `name`, `token`, `scopes` and `expires_at` instead. |
|
||||
| `admin:token:list` | `[<identifier>]`, `--revoked`, `--json` | Lists tokens (hint only, never the hash or plaintext); `--json` prints a JSON array with `id`, `login`, `name`, `scopes`, `hint` and the dates. |
|
||||
| `admin:token:revoke` | `<id>` | Soft-revokes any user's active token by id. |
|
||||
| `admin:token:scopes` | none | Lists framework and plugin scopes available for tokens. |
|
||||
| `admin:token:scopes` | `--json` | Lists framework and plugin scopes available for tokens; `--json` prints a JSON array with `name`, `label`, `read_only`, `full` and `permissions`. |
|
||||
|
||||
```sh
|
||||
./bin/acme admin:create --email admin@example.com --superuser
|
||||
|
||||
@@ -20,6 +20,16 @@ func RuntimeCommands(app *backpack.App) []bonfire.Command {
|
||||
{
|
||||
Name: "admin:create",
|
||||
Description: "Create an activated backend administrator",
|
||||
Long: `Creates an activated backend administrator. The password is asked for
|
||||
without echo on a terminal, or read as one line from stdin when stdin is not
|
||||
a terminal, so a script can pipe it in. --login defaults to the lower-cased
|
||||
email. --superuser grants every permission; --role assigns a role by code.
|
||||
The --password flag still works but is deprecated: the value stays in the
|
||||
shell history and the process list.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "admin:create --email admin@example.com --superuser", Description: "Create a superuser; the password is asked for"},
|
||||
{Command: "admin:create --email editor@example.com --role editor", Description: "Create an administrator with the editor role"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "email", Description: "Admin email"},
|
||||
{Name: "password", Description: "Admin password (deprecated: visible in the process list and shell history; omit it to be prompted, or pipe it on stdin)"},
|
||||
@@ -34,6 +44,13 @@ func RuntimeCommands(app *backpack.App) []bonfire.Command {
|
||||
{
|
||||
Name: "admin:reset-password",
|
||||
Description: "Reset a backend administrator password and revoke existing tokens",
|
||||
Long: `Sets a new password for the administrator with this login or email, read like
|
||||
the one of admin:create (hidden prompt, or one line from stdin), and revokes
|
||||
every session token issued before the reset. Personal API tokens are not
|
||||
touched; revoke them with admin:token:revoke.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "admin:reset-password admin@example.com", Description: "Reset the password; it is asked for"},
|
||||
},
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "identifier",
|
||||
Description: "Login or email",
|
||||
|
||||
@@ -2,6 +2,7 @@ package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
@@ -23,10 +24,21 @@ func tokenRuntimeCommands(app *backpack.App) []bonfire.Command {
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "identifier", Description: "Login or email", Required: true,
|
||||
}},
|
||||
Long: `Creates a personal API token for the administrator with this login or email
|
||||
and prints the plaintext token once, alone on a line, so a script can capture
|
||||
it; it is printed under -q too. Only a hash is stored. Each --scope limits
|
||||
the token to a scope listed by admin:token:scopes; without one the token
|
||||
gets the scopes the administrator's permissions allow. --json prints the
|
||||
token with its id, name, scopes and expiry.`,
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "name", Description: "Token name"},
|
||||
{Name: "scope", Description: "Scope name (repeatable)", Repeatable: true},
|
||||
{Name: "expires", Description: "Expiry: 30d, 90d, 365d, never, or YYYY-MM-DD", Default: "90d"},
|
||||
{Name: "json", Description: "Print the token and its details as JSON", Bare: true},
|
||||
},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "admin:token:create jane --name agent --scope journal.posts", Description: "A token for an agent, limited to one scope"},
|
||||
{Command: "admin:token:create jane --expires never --json", Description: "A token that never expires, as JSON"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return adminTokenCreate(ctx, app, in, out)
|
||||
@@ -38,8 +50,16 @@ func tokenRuntimeCommands(app *backpack.App) []bonfire.Command {
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "identifier", Description: "Login or email (optional)",
|
||||
}},
|
||||
Long: `Lists active personal API tokens, of one administrator or of all, with the
|
||||
token hint (never the hash or the plaintext), scopes and dates. --revoked
|
||||
includes revoked tokens; --json prints them as a JSON array.`,
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "revoked", Description: "Include revoked tokens", Bare: true},
|
||||
{Name: "json", Description: "Print the tokens as JSON", Bare: true},
|
||||
},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "admin:token:list", Description: "Every active token"},
|
||||
{Command: "admin:token:list jane --revoked --json", Description: "Every token of one administrator, as JSON"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return adminTokenList(ctx, app, in, out)
|
||||
@@ -48,6 +68,11 @@ func tokenRuntimeCommands(app *backpack.App) []bonfire.Command {
|
||||
{
|
||||
Name: "admin:token:revoke",
|
||||
Description: "Revoke a personal API token by id",
|
||||
Long: `Revokes one personal API token by the id admin:token:list shows. Requests with
|
||||
it are refused from then on. Revoking a token twice is not an error.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "admin:token:revoke 12", Description: "Revoke token 12"},
|
||||
},
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "id", Description: "Token id", Required: true,
|
||||
}},
|
||||
@@ -58,6 +83,14 @@ func tokenRuntimeCommands(app *backpack.App) []bonfire.Command {
|
||||
{
|
||||
Name: "admin:token:scopes",
|
||||
Description: "List available personal API token scopes",
|
||||
Long: `Lists the scopes a personal API token can be limited to, from the framework and
|
||||
every activated plugin: name, label, whether it is read-only, and the
|
||||
permissions it needs. --json prints them as a JSON array.`,
|
||||
Flags: []bonfire.Flag{{Name: "json", Description: "Print the scopes as JSON", Bare: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "admin:token:scopes", Description: "Show every scope"},
|
||||
{Command: "admin:token:scopes --json", Description: "Machine-readable scope list"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return adminTokenScopes(ctx, app, in, out)
|
||||
},
|
||||
@@ -107,13 +140,22 @@ func adminTokenCreate(ctx context.Context, app *backpack.App, in bonfire.Input,
|
||||
if err != nil || principal == nil {
|
||||
return fmt.Errorf("cabana: could not load admin %q", identifier)
|
||||
}
|
||||
plain, _, details, err := issueAPIToken(gdb, catalog, principal, name, expires, scopes, time.Now().UTC(), true)
|
||||
plain, row, details, err := issueAPIToken(gdb, catalog, principal, name, expires, scopes, time.Now().UTC(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if details != nil {
|
||||
return fmt.Errorf("cabana: %s", firstDetail(details))
|
||||
}
|
||||
if flagValue(in, "json") == "true" && row != nil {
|
||||
return printJSON(out, map[string]any{
|
||||
"id": row.ID,
|
||||
"name": row.Name,
|
||||
"token": plain,
|
||||
"scopes": tokenScopes(row),
|
||||
"expires_at": row.ExpiresAt,
|
||||
})
|
||||
}
|
||||
out.Println(plain)
|
||||
return nil
|
||||
})
|
||||
@@ -143,14 +185,23 @@ func adminTokenList(ctx context.Context, app *backpack.App, in bonfire.Input, ou
|
||||
}
|
||||
headers := []string{"id", "login", "name", "scopes", "hint", "created", "last used", "expires", "revoked"}
|
||||
table := make([][]string, 0, len(rows))
|
||||
list := make([]map[string]any, 0, len(rows))
|
||||
for i := range rows {
|
||||
row := &rows[i]
|
||||
var login string
|
||||
_ = gdb.WithContext(ctx).Raw(`SELECT login FROM backend_users WHERE id = ?`, row.BackendUserID).Scan(&login).Error
|
||||
scopes := ""
|
||||
if row.Scopes.Valid {
|
||||
scopes = strings.Join(row.Scopes.Get(), ",")
|
||||
}
|
||||
scopes := strings.Join(tokenScopes(row), ",")
|
||||
list = append(list, map[string]any{
|
||||
"id": row.ID,
|
||||
"login": login,
|
||||
"name": row.Name,
|
||||
"scopes": tokenScopes(row),
|
||||
"hint": APITokenPrefix + "…" + row.TokenHint,
|
||||
"created_at": row.CreatedAt,
|
||||
"last_used_at": row.LastUsedAt,
|
||||
"expires_at": row.ExpiresAt,
|
||||
"revoked_at": row.RevokedAt,
|
||||
})
|
||||
table = append(table, []string{
|
||||
strconv.FormatUint(uint64(row.ID), 10),
|
||||
login,
|
||||
@@ -163,6 +214,9 @@ func adminTokenList(ctx context.Context, app *backpack.App, in bonfire.Input, ou
|
||||
formatTimePtr(row.RevokedAt),
|
||||
})
|
||||
}
|
||||
if flagValue(in, "json") == "true" {
|
||||
return printJSON(out, list)
|
||||
}
|
||||
out.Table(headers, table)
|
||||
return nil
|
||||
})
|
||||
@@ -207,6 +261,7 @@ func adminTokenScopes(ctx context.Context, app *backpack.App, in bonfire.Input,
|
||||
tr, _ := app.Lookup[*phrasebook.Translator]()
|
||||
headers := []string{"name", "label", "read-only", "permissions"}
|
||||
rows := make([][]string, 0, len(catalog.tokenScopes.ordered))
|
||||
list := make([]map[string]any, 0, len(catalog.tokenScopes.ordered))
|
||||
for _, scope := range catalog.tokenScopes.ordered {
|
||||
label := scope.Label
|
||||
if strings.Contains(label, "::") && tr != nil && tr.Has(label) {
|
||||
@@ -229,11 +284,44 @@ func adminTokenScopes(ctx context.Context, app *backpack.App, in bonfire.Input,
|
||||
perms = "(full)"
|
||||
}
|
||||
rows = append(rows, []string{scope.Name, label, readOnly, perms})
|
||||
permissions := scope.Permissions
|
||||
if permissions == nil {
|
||||
permissions = []string{}
|
||||
}
|
||||
list = append(list, map[string]any{
|
||||
"name": scope.Name,
|
||||
"label": label,
|
||||
"read_only": readOnly == "yes",
|
||||
"full": scope.Full,
|
||||
"permissions": permissions,
|
||||
})
|
||||
}
|
||||
if flagValue(in, "json") == "true" {
|
||||
return printJSON(out, list)
|
||||
}
|
||||
out.Table(headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
// tokenScopes is the token's scope list; never nil.
|
||||
func tokenScopes(row *BackendAPIToken) []string {
|
||||
if row == nil || !row.Scopes.Valid || row.Scopes.Get() == nil {
|
||||
return []string{}
|
||||
}
|
||||
return row.Scopes.Get()
|
||||
}
|
||||
|
||||
// printJSON writes v as indented JSON and a newline on out: data, printed
|
||||
// under -q too.
|
||||
func printJSON(out bonfire.Output, v any) error {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
func argValue(in bonfire.Input, name string) string {
|
||||
if in == nil {
|
||||
return ""
|
||||
|
||||
@@ -226,7 +226,7 @@ queues:
|
||||
|---------|---------------------|-------------|
|
||||
| `queue:work` | `--queue <name>`, repeatable | Runs a job worker in the foreground on the named queues (default: every known queue) until SIGINT or SIGTERM. It prints a startup summary on a terminal (environment, queues with their concurrency, database host, whether the `scheduled` queue runs here) and logs it as a `startup summary` record of component `queue` otherwise. On a signal it stops the worker within 10 seconds, prints and logs a `shutdown report` (jobs done, failed and handed back to the queue, uptime, stop errors) and exits 0, or 1 when the worker fails to stop. An unknown queue is an error that lists the known ones. |
|
||||
| `schedule:run` | `--once` (bare) | Without `--once`: runs a worker on the `scheduled` queue that carries every plugin's periodic jobs, prints a startup summary (entry count and the next due entry; a `startup summary` record of component `schedule` off a terminal) and runs until SIGINT or SIGTERM, then stops within 10 seconds, prints a shutdown report (scheduled commands run and failed, uptime) and exits 0. With `--once`: runs, in-process and without River, every entry due in the current minute of `app.timezone` (a daily entry at its hour and minute; `Every(d)` of a minute or less on every run, a longer one when the minutes since midnight are a multiple of `d`), printing `Running scheduled command: <command> <args>` per entry or `No scheduled commands are ready to run.`. An unregistered command prints a warning and is skipped. It ends with `Ran N due entries (M failed)`; the exit status is the first command error, after every due entry ran. There is no overlap lock: two runs in one minute run the due entries twice, as Laravel does. System cron: `* * * * * cd /app && ./bin/app schedule:run --once`. |
|
||||
| `queue:clear` | `[queue]` (default `default`) | Deletes the available, scheduled and retryable jobs of one queue in batches until none are left and prints `Cleared N jobs`. Running jobs are never touched. |
|
||||
| `queue:clear` | `[queue]` (default `default`), `--force` | Deletes the available, scheduled and retryable jobs of one queue in batches until none are left and prints `Cleared N jobs from <queue>`. Running jobs are never touched. In production on an interactive terminal it asks first; `--force` skips the question, and off a terminal it never asks. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package conga
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -95,17 +96,33 @@ scheduled queue.`,
|
||||
},
|
||||
{
|
||||
Name: "queue:clear",
|
||||
Description: "Clear all queued jobs, by deleting all pending jobs.",
|
||||
Description: "Clear pending jobs from a queue",
|
||||
Long: `Deletes the available, scheduled and retryable jobs of one queue (default
|
||||
"default") in batches until none are left, and prints how many it deleted.
|
||||
Running jobs are never touched. In production, on an interactive terminal, it
|
||||
asks first; --force skips the question. Off a terminal it never asks.`,
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "queue",
|
||||
Description: "The name of the queue to clear (default \"default\")",
|
||||
}},
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "force",
|
||||
Description: "Skip the confirmation asked in production on a terminal",
|
||||
Bare: true,
|
||||
}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "queue:clear", Description: "Clear the default queue"},
|
||||
{Command: "queue:clear imports --force", Description: "Clear the imports queue without the production question"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
queue, _ := in.Argument("queue")
|
||||
queue = strings.TrimSpace(queue)
|
||||
if queue == "" {
|
||||
queue = defaultQueue
|
||||
}
|
||||
if err := confirmProduction(app, in, out, fmt.Sprintf("Delete every pending job of queue %q in production?", queue)); err != nil {
|
||||
return err
|
||||
}
|
||||
return withDB(ctx, app, func() error {
|
||||
return clearQueue(ctx, app, queue, out)
|
||||
})
|
||||
@@ -356,7 +373,33 @@ func clearQueue(ctx context.Context, app *backpack.App, queue string, out bonfir
|
||||
}
|
||||
total += len(res.Jobs)
|
||||
}
|
||||
out.Info(fmt.Sprintf("Cleared %d jobs", total))
|
||||
out.Success(fmt.Sprintf("Cleared %d jobs from %s", total, queue))
|
||||
return nil
|
||||
}
|
||||
|
||||
// confirmProduction asks question before a destructive step when the
|
||||
// environment is production and out is an interactive terminal, unless
|
||||
// --force is set (D-28). Off a terminal, under -n or -q and in
|
||||
// bonfire.Call it never asks. A "no" returns a Reported error (exit 1).
|
||||
func confirmProduction(app *backpack.App, in bonfire.Input, out bonfire.Output, question string) error {
|
||||
if in != nil {
|
||||
if v, ok := in.Flag("force"); ok {
|
||||
if force, _ := strconv.ParseBool(v); force {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if app == nil || app.Config == nil || app.Config.Environment() != "production" {
|
||||
return nil
|
||||
}
|
||||
ok, err := bonfire.ConfirmProceed(out, question)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
out.Warning("Cancelled.")
|
||||
return bonfire.Reported(errors.New("queue:clear cancelled"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ if err := app.Publish[flare.SubscriptionSource](blogSubscriptions{db: gdb}); err
|
||||
| `websockets:generate-vapid-keys [--update] [--show-current]` | Shows the configured keys, truncated to the first 8 and last 4 characters with their length and a check mark when they decode to a valid pair. `--show-current` stops there. Otherwise it generates a new P-256 pair, validates its length and base64url alphabet and prints both keys. With `--update` the keys are saved through `compass.Config.Set` and `compass.Config.Persist` to `env/<environment>/overrides.yaml` in the config directory (mode 0600; other keys in the file are kept). Without it, the command prints `SUMMER_PUSH__PUBLIC_KEY=…` and `SUMMER_PUSH__PRIVATE_KEY=…` lines to set by hand. |
|
||||
| `websockets:test-push <user_id> [--show-config]` | Prints the push configuration: enabled, whether each key is set with its length (never the value), the subject and its format. It then reads the user's subscriptions from the published `flare.SubscriptionSource`, lists them (endpoint shortened to 60 characters, user agent, when subscribed and last used) and asks `Send test notification?` (default yes; a non-interactive run takes the default). It sends one encrypted test push to each subscription and reports each result. `--show-config` is accepted for compatibility; the configuration is always shown. |
|
||||
|
||||
`websockets:test-push` exits 1 when no subscription source is published (`no subscription source registered`), when the user is unknown or has no subscriptions, when push is disabled (it lists the subscriptions but sends nothing), and when any send fails. The test payload is `{"title":"<app.name> test","body":"This is a test push notification sent at HH:MM:SS","data":{"test":true,"timestamp":<unix>}}`.
|
||||
Both commands print their own failure messages and return `bonfire.Reported` errors, so the binary exits 1 without a second error block. `websockets:test-push` exits 1 when no subscription source is published (`no subscription source registered`), when the user is unknown or has no subscriptions, when push is disabled (it lists the subscriptions but sends nothing), and when any send fails. The test payload is `{"title":"<app.name> test","body":"This is a test push notification sent at HH:MM:SS","data":{"test":true,"timestamp":<unix>}}`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -14,12 +14,16 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
// Returned after a command has printed why it failed, so the binary exits 1
|
||||
// without repeating the message.
|
||||
var (
|
||||
errGenerateFailed = errors.New(GenerateVAPIDKeysCommandName + ": failed")
|
||||
errTestPushFailed = errors.New(TestPushCommandName + ": failed")
|
||||
)
|
||||
// generateFailed and testPushFailed are returned after a command has
|
||||
// printed why it failed: bonfire.Reported makes the binary exit 1 without
|
||||
// repeating the message.
|
||||
func generateFailed() error {
|
||||
return bonfire.Reported(errors.New(GenerateVAPIDKeysCommandName + ": failed"))
|
||||
}
|
||||
|
||||
func testPushFailed() error {
|
||||
return bonfire.Reported(errors.New(TestPushCommandName + ": failed"))
|
||||
}
|
||||
|
||||
// Names of the push console commands.
|
||||
const (
|
||||
@@ -49,6 +53,16 @@ func generateVAPIDKeysCommand(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: GenerateVAPIDKeysCommandName,
|
||||
Description: "Generate VAPID keys for Web Push notifications",
|
||||
Long: `Shows the configured VAPID keys (truncated), generates a new P-256 key pair,
|
||||
validates it and prints both keys in full with the environment lines to set.
|
||||
--update writes them to the environment's overrides file instead of leaving
|
||||
that to you; --show-current only shows the configured keys. Changing the keys
|
||||
invalidates every existing browser push subscription.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: GenerateVAPIDKeysCommandName, Description: "Generate a key pair and print it"},
|
||||
{Command: GenerateVAPIDKeysCommandName + " --show-current", Description: "Show the configured keys and stop"},
|
||||
{Command: GenerateVAPIDKeysCommandName + " --update", Description: "Generate a pair and save it to the overrides file"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "update", Bare: true, Description: "Save the new keys to the environment's overrides file"},
|
||||
{Name: "show-current", Bare: true, Description: "Show the configured keys (truncated) and stop"},
|
||||
@@ -67,13 +81,13 @@ func generateVAPIDKeysCommand(app *backpack.App) bonfire.Command {
|
||||
keys, err := GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
out.Error("Failed to generate VAPID keys: " + err.Error())
|
||||
return errGenerateFailed
|
||||
return generateFailed()
|
||||
}
|
||||
out.Success("New keys generated successfully!")
|
||||
out.Println()
|
||||
|
||||
if !validateNewKeys(out, keys) {
|
||||
return errGenerateFailed
|
||||
return generateFailed()
|
||||
}
|
||||
out.Println()
|
||||
out.Println(fmt.Sprintf("Public Key (%d chars):", len(keys.PublicKey)))
|
||||
@@ -88,7 +102,7 @@ func generateVAPIDKeysCommand(app *backpack.App) bonfire.Command {
|
||||
if flagSet(in, "update") {
|
||||
if err := saveKeys(app, keys); err != nil {
|
||||
out.Error("Failed to save the keys: " + err.Error())
|
||||
return errGenerateFailed
|
||||
return generateFailed()
|
||||
}
|
||||
out.Success(fmt.Sprintf("Keys saved to env/%s/overrides.yaml in the config directory", app.Config.Environment()))
|
||||
out.Println()
|
||||
@@ -211,6 +225,15 @@ func testPushCommand(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: TestPushCommandName,
|
||||
Description: "Test push notification system and send test notification",
|
||||
Long: `Shows the push configuration, looks up the user's browser subscriptions through
|
||||
the application's subscription source and, after a confirmation (yes by
|
||||
default, so -n sends without asking), sends each one a test notification. It
|
||||
exits 1 when the configuration is incomplete, the user has no subscription
|
||||
or any send fails, after listing the common causes.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: TestPushCommandName + " 42", Description: "Send a test notification to user 42's browsers"},
|
||||
{Command: TestPushCommandName + " 42 -n", Description: "Send without the confirmation question"},
|
||||
},
|
||||
Args: []bonfire.Arg{{Name: "user_id", Description: "User ID to send test notification to", Required: true}},
|
||||
Flags: []bonfire.Flag{
|
||||
// Accepted for compatibility: the configuration is always shown.
|
||||
@@ -223,7 +246,7 @@ func testPushCommand(app *backpack.App) bonfire.Command {
|
||||
svc, err := From(app)
|
||||
if err != nil {
|
||||
out.Error(err.Error())
|
||||
return errTestPushFailed
|
||||
return testPushFailed()
|
||||
}
|
||||
cfg := svc.Config()
|
||||
showPushConfig(out, cfg)
|
||||
@@ -232,23 +255,23 @@ func testPushCommand(app *backpack.App) bonfire.Command {
|
||||
userID, err := strconv.ParseUint(strings.TrimSpace(rawID), 10, 64)
|
||||
if err != nil || userID == 0 || userID > uint64(^uint(0)) {
|
||||
out.Error(fmt.Sprintf("User %s not found", rawID))
|
||||
return errTestPushFailed
|
||||
return testPushFailed()
|
||||
}
|
||||
|
||||
src, ok := app.Lookup[SubscriptionSource]()
|
||||
if !ok || src == nil {
|
||||
out.Error("no subscription source registered")
|
||||
out.Println("The application must publish a flare.SubscriptionSource to read stored push subscriptions.")
|
||||
return errTestPushFailed
|
||||
return testPushFailed()
|
||||
}
|
||||
subs, err := src.Subscriptions(ctx, uint(userID))
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
out.Error(fmt.Sprintf("User %d not found", userID))
|
||||
return errTestPushFailed
|
||||
return testPushFailed()
|
||||
}
|
||||
if err != nil {
|
||||
out.Error("Failed to read push subscriptions: " + err.Error())
|
||||
return errTestPushFailed
|
||||
return testPushFailed()
|
||||
}
|
||||
out.Info(fmt.Sprintf("Testing push for user ID %d", userID))
|
||||
out.Println()
|
||||
@@ -265,7 +288,7 @@ func testPushCommand(app *backpack.App) bonfire.Command {
|
||||
out.Println(" - Check browser console for errors")
|
||||
out.Println(" - Ensure HTTPS is being used")
|
||||
out.Println(" - Verify service worker is registered")
|
||||
return errTestPushFailed
|
||||
return testPushFailed()
|
||||
}
|
||||
|
||||
out.Success(fmt.Sprintf("Found %d subscription(s)", len(subs)))
|
||||
@@ -291,7 +314,7 @@ func testPushCommand(app *backpack.App) bonfire.Command {
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
out.Error("Push is disabled (push.enabled is false); no notification was sent")
|
||||
return errTestPushFailed
|
||||
return testPushFailed()
|
||||
}
|
||||
return sendTestPush(ctx, out, svc, appName(app), subs)
|
||||
},
|
||||
@@ -378,7 +401,7 @@ func sendTestPush(ctx context.Context, out bonfire.Output, svc *Service, name st
|
||||
out.Println(" - Subscription endpoint no longer valid")
|
||||
out.Println(" - Endpoint host not in push.allowed_hosts")
|
||||
out.Println(" - Push service unavailable or network connectivity issues")
|
||||
return errTestPushFailed
|
||||
return testPushFailed()
|
||||
}
|
||||
out.Success("Test notification sent successfully!")
|
||||
out.Println("Check the browser for the notification.")
|
||||
|
||||
@@ -256,11 +256,13 @@ storage:
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `migrate` | none | Runs the framework migrations (`system_files`, the backend admin tables, `deferred_bindings` and the job queue), then each plugin's migrations in dependency order. |
|
||||
| `migrate:rollback` | `--plugin <id>` | Rolls back the last migration of the given plugin; without the flag, of the last activated plugin that has migrations. |
|
||||
| `migrate:status` | none | Prints a table of plugin, history table and applied migration IDs. |
|
||||
| `deferred:purge` | `--days <n>` | Deletes deferred bindings older than `n` days (default `database.deferred_bindings.purge_days`, else 5), with the unattached uploads they hold (blobs after the commit) and the records created under deferral, and prints the counts. Records that were only linked are kept. Model types come from every plugin's `pact.HasModels`; the uploads bucket from the published one or `storage.uploads.bucket_url`. |
|
||||
| `key:generate` | none | Prints a fresh base64 32-byte key for `app.key`; writes nothing. |
|
||||
| `migrate` | `--pretend`, `--force` | Runs the framework migrations (`system_files`, the backend admin tables, `deferred_bindings` and the job queue), then each plugin's migrations in dependency order; prints `<set>: N applied` per set and `Applied N migrations`, or `Nothing to migrate`. `--pretend` prints each pending migration as `<plugin>: <id>` and `N migrations would run`, and applies nothing. In production on an interactive terminal it asks first (`--force` skips the question); off a terminal it never asks. |
|
||||
| `migrate:rollback` | `--plugin <id>`, `--pretend`, `--force` | Rolls back the last applied migration of the given plugin; without the flag, of the last activated plugin that has migrations. Prints `Rolled back <id> of <plugin>`; `--pretend` names the migration and changes nothing. Production confirmation as for `migrate`. |
|
||||
| `migrate:status` | `--json` | Prints a table of plugin, history table and applied migration IDs. `--json` prints a JSON array of every declared migration of those plugins: `plugin`, `table`, `id`, `applied`. |
|
||||
| `deferred:purge` | `--days <n>`, `--pretend`, `--force` | Deletes deferred bindings older than `n` days (default `database.deferred_bindings.purge_days`, else 5), with the unattached uploads they hold (blobs after the commit) and the records created under deferral, and prints the counts. Records that were only linked are kept. Model types come from every plugin's `pact.HasModels`; the uploads bucket from the published one or `storage.uploads.bucket_url`. `--pretend` counts the bindings past the cut-off and deletes nothing; production confirmation as for `migrate`. |
|
||||
| `key:generate` | `--json` | Prints a fresh base64 32-byte key for `app.key`; writes nothing. Under `-q` it prints only the key; `--json` prints `{"key": "..."}`. |
|
||||
|
||||
An empty `database.dsn` and an unreachable database fail with a `bonfire.WithHint` explanation and a Try line (`cp .env.example .env`, `docker compose up -d --wait`); an empty or invalid `app.key` suggests `{bin} key:generate`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package lagoon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
@@ -17,6 +19,98 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Flags shared by the migration commands; each destructive command declares
|
||||
// its own --force.
|
||||
var (
|
||||
pretendFlag = bonfire.Flag{Name: "pretend", Description: "Show what would change without changing anything", Bare: true}
|
||||
jsonFlag = bonfire.Flag{Name: "json", Description: "Print the result as JSON", Bare: true}
|
||||
)
|
||||
|
||||
// forceDescription describes the --force flag of the destructive commands.
|
||||
const forceDescription = "Skip the confirmation asked in production on a terminal"
|
||||
|
||||
// flagOn reports whether the bare flag name is set; a nil Input has none.
|
||||
func flagOn(in bonfire.Input, name string) bool {
|
||||
if in == nil {
|
||||
return false
|
||||
}
|
||||
v, ok := in.Flag(name)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
on, _ := strconv.ParseBool(v)
|
||||
return on
|
||||
}
|
||||
|
||||
// confirmProduction asks question before a destructive step when the
|
||||
// environment is production and out is an interactive terminal, unless
|
||||
// --force is set (D-28). Off a terminal, under -n or -q and in
|
||||
// bonfire.Call it never asks, so deploys over SSH proceed as before. A
|
||||
// "no" prints cancelled and returns a Reported error (exit 1).
|
||||
func confirmProduction(app *backpack.App, in bonfire.Input, out bonfire.Output, question, cancelled string) error {
|
||||
if flagOn(in, "force") || app == nil || app.Config == nil || app.Config.Environment() != "production" {
|
||||
return nil
|
||||
}
|
||||
ok, err := bonfire.ConfirmProceed(out, question)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
out.Warning(cancelled)
|
||||
return bonfire.Reported(errors.New(cancelled))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lastApplied is the last migration of pluginID, in declaration order, that
|
||||
// its history table holds; "" when none is applied.
|
||||
func lastApplied(gdb *gorm.DB, plugins []party.Plugin, pluginID string) (string, error) {
|
||||
var target party.Plugin
|
||||
for _, p := range plugins {
|
||||
if p.ID() == pluginID {
|
||||
target = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return "", fmt.Errorf("%w: %q", ErrUnknownPlugin, pluginID)
|
||||
}
|
||||
hm, ok := target.(pact.HasMigrations)
|
||||
if !ok || len(hm.Migrations()) == 0 {
|
||||
return "", fmt.Errorf("%w: %q", ErrNoMigrations, pluginID)
|
||||
}
|
||||
table, err := HistoryTableName(pluginID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !gdb.Migrator().HasTable(table) {
|
||||
return "", nil
|
||||
}
|
||||
var ids []string
|
||||
if err := gdb.Table(table).Pluck("id", &ids).Error; err != nil {
|
||||
return "", fmt.Errorf("lagoon: status %s: %w", pluginID, err)
|
||||
}
|
||||
applied := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
applied[id] = true
|
||||
}
|
||||
ms := hm.Migrations()
|
||||
for i := len(ms) - 1; i >= 0; i-- {
|
||||
if ms[i] != nil && applied[ms[i].ID] {
|
||||
return ms[i].ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// statusJSON is one migration of migrate:status --json.
|
||||
type statusJSON struct {
|
||||
Plugin string `json:"plugin"`
|
||||
Table string `json:"table"`
|
||||
ID string `json:"id"`
|
||||
Applied bool `json:"applied"`
|
||||
}
|
||||
|
||||
// RuntimeCommands returns migrate, migrate:rollback, migrate:status,
|
||||
// deferred:purge and key:generate. Serve is registered separately via
|
||||
// surf.ServeCommand.
|
||||
@@ -25,12 +119,56 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
{
|
||||
Name: "migrate",
|
||||
Description: "Run plugin migrations in dependency order",
|
||||
Long: `Applies the pending migrations: the framework's own sets (uploads, backend
|
||||
admin, deferred bindings, job queue) first, then each plugin's in dependency
|
||||
order. Each plugin keeps its history in its own summer_migrations_<plugin>
|
||||
table, so a migration runs once.
|
||||
|
||||
--pretend lists the pending migrations and applies nothing. In production, on
|
||||
an interactive terminal, migrate asks before applying; --force skips the
|
||||
question. Off a terminal (a deploy over SSH, a container entrypoint) it never
|
||||
asks.`,
|
||||
Flags: []bonfire.Flag{pretendFlag, {Name: "force", Description: forceDescription, Bare: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "migrate", Description: "Apply every pending migration"},
|
||||
{Command: "migrate --pretend", Description: "List the pending migrations without applying them"},
|
||||
{Command: "migrate --force", Description: "In production, apply without the confirmation question"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return withDB(ctx, app, func(gdb *gorm.DB) error {
|
||||
pending, err := PendingMigrations(gdb, plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flagOn(in, "pretend") {
|
||||
for _, ref := range pending {
|
||||
out.Println(ref.Plugin + ": " + ref.ID)
|
||||
}
|
||||
out.Info(fmt.Sprintf("%d migrations would run", len(pending)))
|
||||
return nil
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
out.Success("Nothing to migrate")
|
||||
return nil
|
||||
}
|
||||
if err := confirmProduction(app, in, out, fmt.Sprintf("Apply %d migrations to the production database?", len(pending)), "Migration cancelled."); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := Migrate(gdb, plugins); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Success("migrations applied")
|
||||
counts := map[string]int{}
|
||||
var order []string
|
||||
for _, ref := range pending {
|
||||
if counts[ref.Plugin] == 0 {
|
||||
order = append(order, ref.Plugin)
|
||||
}
|
||||
counts[ref.Plugin]++
|
||||
}
|
||||
for _, plugin := range order {
|
||||
out.Info(fmt.Sprintf("%s: %d applied", plugin, counts[plugin]))
|
||||
}
|
||||
out.Success(fmt.Sprintf("Applied %d migrations", len(pending)))
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -38,20 +176,55 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
{
|
||||
Name: "migrate:rollback",
|
||||
Description: "Roll back the last migration of a plugin",
|
||||
Long: `Rolls back the last applied migration of one plugin (--plugin), or of the
|
||||
last plugin in activation order that has migrations. Run it again to roll
|
||||
back further. --pretend names the migration that would roll back. In
|
||||
production, on an interactive terminal, it asks first; --force skips the
|
||||
question.`,
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "plugin",
|
||||
Description: "Plugin ID whose last migration to roll back",
|
||||
}},
|
||||
}, pretendFlag, {Name: "force", Description: forceDescription, Bare: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "migrate:rollback --plugin acme.blog", Description: "Roll back the last migration of acme.blog"},
|
||||
{Command: "migrate:rollback --plugin acme.blog --pretend", Description: "Name the migration that would roll back"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
plugin, _ := in.Flag("plugin")
|
||||
plugin := ""
|
||||
if in != nil {
|
||||
plugin, _ = in.Flag("plugin")
|
||||
}
|
||||
plugin = strings.TrimSpace(plugin)
|
||||
if plugin == "" {
|
||||
plugin = lastMigrationPlugin(plugins)
|
||||
}
|
||||
if plugin == "" {
|
||||
return ErrNoMigrations
|
||||
}
|
||||
return withDB(ctx, app, func(gdb *gorm.DB) error {
|
||||
id, err := lastApplied(gdb, plugins, plugin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flagOn(in, "pretend") {
|
||||
if id == "" {
|
||||
out.Info(fmt.Sprintf("%s has no applied migration to roll back", plugin))
|
||||
return nil
|
||||
}
|
||||
out.Info(fmt.Sprintf("Would roll back %s of %s", id, plugin))
|
||||
return nil
|
||||
}
|
||||
if err := confirmProduction(app, in, out, fmt.Sprintf("Roll back %s of %s on the production database?", id, plugin), "Rollback cancelled."); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := RollbackLast(gdb, plugins, plugin); err != nil {
|
||||
return err
|
||||
}
|
||||
if plugin == "" {
|
||||
plugin = lastMigrationPlugin(plugins)
|
||||
if id == "" {
|
||||
out.Success(fmt.Sprintf("Rolled back the last migration of %s", plugin))
|
||||
return nil
|
||||
}
|
||||
out.Success(fmt.Sprintf("rolled back last migration of %s", plugin))
|
||||
out.Success(fmt.Sprintf("Rolled back %s of %s", id, plugin))
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -59,12 +232,38 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
{
|
||||
Name: "migrate:status",
|
||||
Description: "Show per-plugin migration history",
|
||||
Long: `Lists each plugin with migrations, its history table and the migration IDs
|
||||
applied. --json prints every declared migration of those plugins with its
|
||||
table and whether it is applied, for scripts.`,
|
||||
Flags: []bonfire.Flag{jsonFlag},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "migrate:status", Description: "Show the applied migrations per plugin"},
|
||||
{Command: "migrate:status --json", Description: "Machine-readable status, pending migrations included"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return withDB(ctx, app, func(gdb *gorm.DB) error {
|
||||
rows, err := Status(gdb, plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flagOn(in, "json") {
|
||||
pending, err := PendingMigrations(gdb, plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
list := []statusJSON{}
|
||||
for _, row := range rows {
|
||||
for _, id := range row.IDs {
|
||||
list = append(list, statusJSON{Plugin: row.Plugin, Table: row.Table, ID: id, Applied: true})
|
||||
}
|
||||
for _, ref := range pending {
|
||||
if ref.Plugin == row.Plugin {
|
||||
list = append(list, statusJSON{Plugin: row.Plugin, Table: row.Table, ID: ref.ID})
|
||||
}
|
||||
}
|
||||
}
|
||||
return printJSON(out, list)
|
||||
}
|
||||
tableRows := make([][]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids := "(none)"
|
||||
@@ -81,16 +280,38 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
{
|
||||
Name: "deferred:purge",
|
||||
Description: "Delete expired deferred bindings with their pending uploads and created records",
|
||||
Long: `Deletes deferred bindings older than --days (default
|
||||
database.deferred_bindings.purge_days, else 5): uploads attached to a form that
|
||||
was never saved, with their files, and records created under deferral. Run it
|
||||
daily from the scheduler or cron. --pretend counts the bindings that would be
|
||||
purged and deletes nothing. In production, on an interactive terminal, it
|
||||
asks first; --force skips the question.`,
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "days",
|
||||
Description: "Purge bindings older than this many days",
|
||||
}},
|
||||
}, pretendFlag, {Name: "force", Description: forceDescription, Bare: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "deferred:purge", Description: "Purge bindings older than the configured number of days"},
|
||||
{Command: "deferred:purge --days 2 --pretend", Description: "Count the bindings older than two days"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
days, err := purgeDays(app, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
||||
return withDB(ctx, app, func(gdb *gorm.DB) error {
|
||||
if flagOn(in, "pretend") {
|
||||
var n int64
|
||||
if err := gdb.WithContext(ctx).Model(&DeferredBinding{}).Where("created_at < ?", before).Count(&n).Error; err != nil {
|
||||
return fmt.Errorf("lagoon: count deferred bindings: %w", err)
|
||||
}
|
||||
out.Info(fmt.Sprintf("%d deferred bindings older than %d days would be purged", n, days))
|
||||
return nil
|
||||
}
|
||||
if err := confirmProduction(app, in, out, fmt.Sprintf("Purge deferred bindings older than %d days from the production database?", days), "Purge cancelled."); err != nil {
|
||||
return err
|
||||
}
|
||||
models, err := deferredModels(gdb, plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -101,7 +322,7 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
}
|
||||
defer closeBucket()
|
||||
res, err := PurgeDeferred(ctx, gdb, bucket, PurgeOptions{
|
||||
Before: time.Now().Add(-time.Duration(days) * 24 * time.Hour),
|
||||
Before: before,
|
||||
Models: func(slaveType string) (any, bool) {
|
||||
m, ok := models[slaveType]
|
||||
return m, ok
|
||||
@@ -120,6 +341,17 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
}
|
||||
}
|
||||
|
||||
// printJSON writes v as indented JSON and a newline on out (data: printed
|
||||
// under -q too).
|
||||
func printJSON(out bonfire.Output, v any) error {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
// purgeDays is the --days flag, else database.deferred_bindings.purge_days,
|
||||
// else WinterCMS's five days. A negative or non-integer value is an error.
|
||||
func purgeDays(app *backpack.App, in bonfire.Input) (int, error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
@@ -31,7 +32,9 @@ var moduleLog = diary.For("lagoon")
|
||||
func Open(ctx context.Context, dsn string) (*sql.DB, *gorm.DB, error) {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn == "" {
|
||||
return nil, nil, fmt.Errorf("lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)")
|
||||
return nil, nil, bonfire.WithHint(fmt.Errorf("lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)"),
|
||||
"The application reads database.dsn from config/database.yaml or SUMMER_DATABASE__DSN.",
|
||||
"cp .env.example .env")
|
||||
}
|
||||
sqlDB, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
@@ -39,7 +42,9 @@ func Open(ctx context.Context, dsn string) (*sql.DB, *gorm.DB, error) {
|
||||
}
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, nil, fmt.Errorf("lagoon: ping postgres: %w", err)
|
||||
return nil, nil, bonfire.WithHint(fmt.Errorf("lagoon: ping postgres: %w", err),
|
||||
"PostgreSQL is not reachable at the configured host.",
|
||||
"docker compose up -d --wait")
|
||||
}
|
||||
gdb, err := gormFromSQL(sqlDB)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
@@ -343,7 +344,9 @@ func loadEncryptionKeysFromApp(app *backpack.App) error {
|
||||
}
|
||||
primary, previous, err := LoadAppKey(app.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
return bonfire.WithHint(err,
|
||||
"app.key encrypts Encrypted columns; set it in SUMMER_APP__KEY or config/app.yaml.",
|
||||
"{bin} key:generate")
|
||||
}
|
||||
return PublishEncryptionKeys(app, primary, previous)
|
||||
}
|
||||
|
||||
@@ -20,17 +20,36 @@ func GenerateAppKey() (string, error) {
|
||||
}
|
||||
|
||||
// KeyGenerateCommand prints a fresh 32-byte base64 app.key and performs no
|
||||
// other side effect (D-11).
|
||||
// other side effect (D-11). --json prints {"key": "..."}; under -q the bare
|
||||
// key is printed, because the key is the data the command exists for (D-27).
|
||||
func KeyGenerateCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "key:generate",
|
||||
Description: "Print a fresh 32-byte base64 app.key",
|
||||
Long: `Prints a new random app.key (32 bytes, base64) and changes nothing: put it in
|
||||
SUMMER_APP__KEY or config/app.yaml yourself. app.key encrypts Encrypted
|
||||
columns and signs sessions, so changing it on a running application makes
|
||||
existing encrypted values unreadable; move the old key to app.previous_keys
|
||||
first. With -q only the key is printed; --json prints {"key": "..."}.`,
|
||||
Flags: []bonfire.Flag{jsonFlag},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "key:generate", Description: "Print a new key"},
|
||||
{Command: "key:generate -q", Description: "Print only the key, for scripts: KEY=$(bin/acme key:generate -q)"},
|
||||
{Command: "key:generate --json", Description: "Print the key as JSON"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
key, err := GenerateAppKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Success(key)
|
||||
switch {
|
||||
case flagOn(in, "json"):
|
||||
return printJSON(out, map[string]string{"key": key})
|
||||
case bonfire.IsQuiet(out):
|
||||
out.Println(key)
|
||||
default:
|
||||
out.Success(key)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ for _, pub := range mem.Publications() {
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `websockets:health` | With an empty `realtime.centrifugo.api_key` it prints `Centrifugo not configured (API key missing)` and exits 1 without sending a request. Otherwise it prints the API URL and calls the Centrifugo `info` API method. On success it prints `Configuration OK` and a Setting/Value table (API URL, Enabled, API Key Set); on any failure it prints `Connection check failed: …` and exits 1. The API key is never printed, only whether it is set. |
|
||||
| `websockets:health` | With an empty `realtime.centrifugo.api_key` it prints `Centrifugo not configured (API key missing)` and exits 1 without sending a request. Otherwise it prints the API URL and calls the Centrifugo `info` API method. On success it prints `Configuration OK` and a Setting/Value table (API URL, Enabled, API Key Set); on any failure it prints `Connection check failed: …` and exits 1. With `--json` it prints only `{"healthy", "api_url", "enabled", "api_key_set", "error"}` and exits 1 when unhealthy. The API key is never printed, only whether it is set. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package centrifugo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
@@ -11,9 +12,21 @@ import (
|
||||
// HealthCommandName is the name of the Centrifugo health check command.
|
||||
const HealthCommandName = "websockets:health"
|
||||
|
||||
// errHealthFailed is returned by the health command after it has printed
|
||||
// why the check failed, so the binary exits 1.
|
||||
var errHealthFailed = errors.New(HealthCommandName + ": check failed")
|
||||
// healthFailed is returned by the health command after it has printed why
|
||||
// the check failed: bonfire.Reported makes the binary exit 1 without
|
||||
// repeating the message.
|
||||
func healthFailed() error {
|
||||
return bonfire.Reported(errors.New(HealthCommandName + ": check failed"))
|
||||
}
|
||||
|
||||
// healthJSON is websockets:health --json. The API key is never included.
|
||||
type healthJSON struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
APIURL string `json:"api_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKeySet bool `json:"api_key_set"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Commands returns the Centrifugo console commands for an application
|
||||
// binary: the HealthCommandName check. An application appends them to the
|
||||
@@ -30,7 +43,17 @@ func healthCommand(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: HealthCommandName,
|
||||
Description: "Check Centrifugo connection health",
|
||||
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
|
||||
Long: `Reads the Centrifugo settings (realtime.centrifugo.*) and calls the Centrifugo
|
||||
info API with them, so a wrong URL or API key fails the check. The API key is
|
||||
never printed. It exits 1 when Centrifugo is not configured or does not
|
||||
answer. --json prints {healthy, api_url, enabled, api_key_set, error} for
|
||||
monitoring scripts.`,
|
||||
Flags: []bonfire.Flag{{Name: "json", Description: "Print the result as JSON", Bare: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: HealthCommandName, Description: "Check the connection"},
|
||||
{Command: HealthCommandName + " --json", Description: "Machine-readable result for a monitoring check"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
var cfg Config
|
||||
if app != nil {
|
||||
cfg = LoadConfig(app.Config)
|
||||
@@ -38,16 +61,41 @@ func healthCommand(app *backpack.App) bonfire.Command {
|
||||
cfg = LoadConfig(nil)
|
||||
}
|
||||
client := NewClient(cfg, nil)
|
||||
asJSON := false
|
||||
if in != nil {
|
||||
v, _ := in.Flag("json")
|
||||
asJSON = v == "true"
|
||||
}
|
||||
if asJSON {
|
||||
info := client.DebugInfo()
|
||||
res := healthJSON{APIURL: info.APIURL, Enabled: info.Enabled, APIKeySet: info.APIKeySet}
|
||||
if !client.Enabled() {
|
||||
res.Error = "Centrifugo not configured (API key missing)"
|
||||
} else if _, err := client.Info(ctx); err != nil {
|
||||
res.Error = err.Error()
|
||||
} else {
|
||||
res.Healthy = true
|
||||
}
|
||||
b, err := json.MarshalIndent(res, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Println(string(b))
|
||||
if !res.Healthy {
|
||||
return healthFailed()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !client.Enabled() {
|
||||
out.Error("Centrifugo not configured (API key missing)")
|
||||
out.Println("Set SUMMER_REALTIME__CENTRIFUGO__API_KEY in the environment")
|
||||
return errHealthFailed
|
||||
return healthFailed()
|
||||
}
|
||||
out.Info("Checking Centrifugo connection...")
|
||||
out.Println("API URL: " + cfg.APIURL)
|
||||
if _, err := client.Info(ctx); err != nil {
|
||||
out.Error("Connection check failed: " + err.Error())
|
||||
return errHealthFailed
|
||||
return healthFailed()
|
||||
}
|
||||
info := client.DebugInfo()
|
||||
out.Success("Configuration OK")
|
||||
|
||||
@@ -197,7 +197,7 @@ The `serve` command also opens the database through [lagoon](../lagoon/README.md
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, installs cooler (the shared rate-limit store when `http.rate_limit.store` is `redis`), assembles the router, binds the address, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM. Once it listens it prints the startup summary on a terminal (unless `-q`), with the admin URL and a warning naming `summer admin:create` when no administrator exists yet, or `off` with the steps to add an admin controller; off a terminal it logs the summary and `listening on <addr>` as records. On a signal it drains requests and the worker within 10 seconds, prints and logs the shutdown report, and exits 0, or 1 when draining fails. |
|
||||
| `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route, followed by the admin status line (`admin: mounted at <prefix>` or `admin: off`). |
|
||||
| `route:list` | `--json` | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route, followed by the admin status line (`admin: mounted at <prefix>` or `admin: off`). `--json` prints a JSON array of `method`, `pattern`, `plugin`, `middleware` and `raw` instead. A missing uploads bucket makes `serve` fail with a hint naming `config/storage.yaml`. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package surf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -10,6 +11,18 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// jsonFlagName is route:list's --json flag.
|
||||
const jsonFlagName = "json"
|
||||
|
||||
// routeJSON is one route of route:list --json.
|
||||
type routeJSON struct {
|
||||
Method string `json:"method"`
|
||||
Pattern string `json:"pattern"`
|
||||
Plugin string `json:"plugin"`
|
||||
Middleware []string `json:"middleware"`
|
||||
Raw bool `json:"raw"`
|
||||
}
|
||||
|
||||
// RouteListCommand builds the router the same way ServeCommand does, then
|
||||
// renders Routes() as a table instead of serving, followed by the admin
|
||||
// status line.
|
||||
@@ -17,12 +30,40 @@ func RouteListCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command
|
||||
return bonfire.Command{
|
||||
Name: "route:list",
|
||||
Description: "List registered HTTP routes",
|
||||
Long: `Builds the router the way serve does, without opening the database or
|
||||
listening, and prints every route: method, pattern, the plugin that
|
||||
registered it, its named middleware and whether it is raw (no JSON envelope).
|
||||
The admin status line follows. --json prints the routes as a JSON array for
|
||||
scripts.`,
|
||||
Flags: []bonfire.Flag{{Name: jsonFlagName, Description: "Print the routes as JSON", Bare: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "route:list", Description: "Show every route as a table"},
|
||||
{Command: "route:list --json", Description: "Machine-readable route list"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
r, err := BuildRouter(app, plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
infos := r.Routes()
|
||||
if in != nil {
|
||||
if v, _ := in.Flag(jsonFlagName); v == "true" {
|
||||
list := make([]routeJSON, 0, len(infos))
|
||||
for _, rt := range infos {
|
||||
mw := rt.Middleware
|
||||
if mw == nil {
|
||||
mw = []string{}
|
||||
}
|
||||
list = append(list, routeJSON{Method: rt.Method, Pattern: rt.Pattern, Plugin: rt.PluginID, Middleware: mw, Raw: rt.Raw})
|
||||
}
|
||||
b, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
rows := make([][]string, 0, len(infos))
|
||||
for _, rt := range infos {
|
||||
rows = append(rows, []string{
|
||||
|
||||
@@ -212,7 +212,9 @@ func publishUploads(ctx context.Context, app *backpack.App) (*blob.Bucket, error
|
||||
}
|
||||
bucket, err := attach.OpenBucket(ctx, app.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, bonfire.WithHint(err,
|
||||
"serve stores uploads in the bucket named by storage.uploads.bucket_url in config/storage.yaml (or SUMMER_STORAGE__UPLOADS__BUCKET_URL).",
|
||||
"SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://./storage/app/uploads {bin} serve")
|
||||
}
|
||||
if err := attach.Publish(app, bucket); err != nil {
|
||||
_ = bucket.Close()
|
||||
|
||||
Reference in New Issue
Block a user