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:
Jakub Zych
2026-10-21 13:16:21 +02:00
parent 168d65cd1b
commit 9d06a78f62
29 changed files with 831 additions and 77 deletions

View File

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

View File

@@ -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",

View File

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