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:
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user