feat: console help, examples and named loggers (SummerCMS v0.4.0)
Every command gets long help and examples, descriptions in imperative sentence case, and a warn-only TestCommandsDocumented over bonfire.CheckDocs. The plugin logs through a diary component logger instead of the process default logger. Requires SummerCMS v0.4.0; command names and flags unchanged. user:token:create and user:token:list gain --json, user:backfill-handles a progress bar; planning IDs leave the descriptions.
This commit is contained in:
@@ -2,6 +2,7 @@ package console
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -28,11 +29,25 @@ func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm
|
||||
return []bonfire.Command{
|
||||
{
|
||||
Name: "user:token:create", Description: "Create one API token usable by application plugins",
|
||||
Long: `Mints a personal API token that acts as the user with this e-mail, for
|
||||
scripts and integrations that call the application's API. --scopes limits
|
||||
what it may do (read, write, ai); --expires sets an RFC3339 expiry, and
|
||||
without it the token does not expire. The secret is printed once, on its own
|
||||
line (also under -q), and only its hash is stored, so keep it now; with
|
||||
--json the token and its details are printed as one JSON object instead.
|
||||
|
||||
Revoke a token with user:token:revoke and list them with user:token:list.`,
|
||||
Args: []bonfire.Arg{{Name: "email", Description: "E-mail of the user the token acts as", Required: true}},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "name", Description: "What the token is for", Default: "api"},
|
||||
{Name: "scopes", Description: "Comma-separated scopes: read, write, ai", Default: "read,write"},
|
||||
{Name: "expires", Description: "RFC3339 expiry; empty means no expiry"},
|
||||
{Name: "json", Description: "Print the token and its details as JSON", Bare: true},
|
||||
},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "user:token:create jane@example.com --name backup-script --scopes read", Description: "A read-only token for a script"},
|
||||
{Command: "user:token:create jane@example.com --expires 2027-01-01T00:00:00Z", Description: "A read and write token that expires"},
|
||||
{Command: "user:token:create jane@example.com --json", Description: "Print the token and its details as JSON"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
email, _ := in.Argument("email")
|
||||
@@ -71,6 +86,17 @@ func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm
|
||||
if err := db.Create(token).Error; err != nil {
|
||||
return fmt.Errorf("user:token:create: %w", err)
|
||||
}
|
||||
if flagOn(in, "json") {
|
||||
return printJSON(out, map[string]any{
|
||||
"id": token.ID,
|
||||
"user_id": token.UserID,
|
||||
"email": user.Email,
|
||||
"name": token.Name,
|
||||
"token": secret,
|
||||
"scopes": token.Scopes.Get(),
|
||||
"expires_at": token.ExpiresAt,
|
||||
})
|
||||
}
|
||||
out.Printf("token %d for %s, scopes %s\n", token.ID, email, strings.Join(token.Scopes.Get(), ","))
|
||||
out.Println(secret)
|
||||
out.Success("user:token:create: store the secret now; it is not shown again")
|
||||
@@ -80,13 +106,25 @@ func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm
|
||||
},
|
||||
{
|
||||
Name: "user:token:list", Description: "List API tokens",
|
||||
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
|
||||
Long: `Lists every personal API token: its id, the user it acts as, its name, its
|
||||
scopes and its state (active, expired or revoked). Secrets are never shown;
|
||||
only their hashes are stored. --json prints the tokens as a JSON array with
|
||||
their expiry, revocation and last use times.`,
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "json", Description: "Print the tokens as JSON", Bare: true},
|
||||
},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "user:token:list", Description: "Show every token as a table"},
|
||||
{Command: "user:token:list --json", Description: "Print the tokens as JSON for a script"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return open(ctx, func(db *gorm.DB) error {
|
||||
var tokens []models.APIToken
|
||||
if err := db.Order("id").Find(&tokens).Error; err != nil {
|
||||
return fmt.Errorf("user:token:list: %w", err)
|
||||
}
|
||||
rows := make([][]string, 0, len(tokens))
|
||||
list := make([]map[string]any, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
state := "active"
|
||||
if token.RevokedAt != nil {
|
||||
@@ -99,6 +137,20 @@ func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm
|
||||
name = *token.Name
|
||||
}
|
||||
rows = append(rows, []string{strconv.FormatUint(uint64(token.ID), 10), strconv.FormatUint(uint64(token.UserID), 10), name, strings.Join(token.Scopes.Get(), ","), state})
|
||||
list = append(list, map[string]any{
|
||||
"id": token.ID,
|
||||
"user_id": token.UserID,
|
||||
"name": token.Name,
|
||||
"scopes": token.Scopes.Get(),
|
||||
"state": state,
|
||||
"expires_at": token.ExpiresAt,
|
||||
"revoked_at": token.RevokedAt,
|
||||
"last_used_at": token.LastUsedAt,
|
||||
"created_at": token.CreatedAt,
|
||||
})
|
||||
}
|
||||
if flagOn(in, "json") {
|
||||
return printJSON(out, list)
|
||||
}
|
||||
out.Table([]string{"id", "user", "name", "scopes", "state"}, rows)
|
||||
return nil
|
||||
@@ -107,7 +159,13 @@ func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm
|
||||
},
|
||||
{
|
||||
Name: "user:token:revoke", Description: "Revoke an API token",
|
||||
Long: `Revokes the active API token with this id (see user:token:list): requests
|
||||
that present it are refused from now on. The token row stays, marked
|
||||
revoked; a token that is already revoked or does not exist is an error.`,
|
||||
Args: []bonfire.Arg{{Name: "id", Description: "Token id", Required: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "user:token:revoke 12", Description: "Revoke the token with id 12"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
raw, _ := in.Argument("id")
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
@@ -129,3 +187,24 @@ func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// flagOn reads a bare flag ("true" when given).
|
||||
func flagOn(in bonfire.Input, name string) bool {
|
||||
v, _ := in.Flag(name)
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "true", "1", "yes":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -19,7 +19,18 @@ import (
|
||||
func BackfillHandles(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "user:backfill-handles",
|
||||
Description: "Assign unique handles to users that do not have one (D-15)",
|
||||
Description: "Assign unique handles to users that do not have one",
|
||||
Long: `Gives every user without a handle a unique one, derived the same way as
|
||||
for a new registration, with a progress bar over the users (hidden under
|
||||
-q). Users that already have a handle are not touched, so running it again
|
||||
changes nothing.
|
||||
|
||||
Run it once after copying users from WinterCMS, which had no handles. It
|
||||
ends with "Assigned handles to N user(s)".`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "user:backfill-handles", Description: "Assign a handle to every user that has none"},
|
||||
{Command: "user:backfill-handles -q", Description: "Run it quietly, printing only the result line"},
|
||||
},
|
||||
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("user:backfill-handles: app is nil")
|
||||
@@ -38,11 +49,20 @@ func BackfillHandles(app *backpack.App) bonfire.Command {
|
||||
return err
|
||||
}
|
||||
n := 0
|
||||
for i := range users {
|
||||
if err := classes.AssignHandle(ctx, gdb, &users[i]); err != nil {
|
||||
if len(users) > 0 {
|
||||
err := out.Progress(len(users), func(p bonfire.Progress) error {
|
||||
for i := range users {
|
||||
if err := classes.AssignHandle(ctx, gdb, &users[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
n++
|
||||
p.Advance(1)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n++
|
||||
}
|
||||
out.Success(fmt.Sprintf("Assigned handles to %d user(s)", n))
|
||||
return nil
|
||||
|
||||
@@ -15,7 +15,14 @@ import (
|
||||
func RequirePasswordChange(withDB func(context.Context, func(*gorm.DB) error) error) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "user:require-password-change",
|
||||
Description: "Force a user to change their password on next login (D-21)",
|
||||
Description: "Force a user to change their password on next login",
|
||||
Long: `Marks the account with this e-mail so that it must set a new password at its
|
||||
next sign-in; setting the password clears the mark. Use it after a suspected
|
||||
leak or when handing over an account created with a temporary password. It
|
||||
ends with "Password change required for <email>".`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "user:require-password-change jane@example.com", Description: "Make Jane set a new password at her next sign-in"},
|
||||
},
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "email",
|
||||
Description: "Account email",
|
||||
|
||||
Reference in New Issue
Block a user