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

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

View File

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