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:
@@ -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