- 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
284 lines
8.0 KiB
Go
284 lines
8.0 KiB
Go
package bonfire
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/sunscreen"
|
|
"github.com/charmbracelet/colorprofile"
|
|
)
|
|
|
|
// UsageError marks a mistake in how a command was called: an unknown
|
|
// command or flag, or a wrong number of arguments. Execute returns ExitUsage
|
|
// for it.
|
|
type UsageError struct {
|
|
Err error
|
|
// command is the command that was called wrongly, when known; the
|
|
// error block suggests its help.
|
|
command string
|
|
}
|
|
|
|
func (e *UsageError) Error() string { return e.Err.Error() }
|
|
|
|
func (e *UsageError) Unwrap() error { return e.Err }
|
|
|
|
// Usage wraps err as a UsageError; nil stays nil.
|
|
func Usage(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var ue *UsageError
|
|
if errors.As(err, &ue) {
|
|
return err
|
|
}
|
|
return &UsageError{Err: err}
|
|
}
|
|
|
|
// isUsage reports a UsageError in the chain or cobra's unknown command error.
|
|
func isUsage(err error) bool {
|
|
var ue *UsageError
|
|
if errors.As(err, &ue) {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(err.Error(), "unknown command")
|
|
}
|
|
|
|
// HintError adds an explanation and a command to try to an error. The error
|
|
// block prints both under the error message; {bin} in Try becomes summer or
|
|
// bin/<name>.
|
|
type HintError struct {
|
|
Err error
|
|
Explanation string
|
|
Try string
|
|
}
|
|
|
|
func (e *HintError) Error() string { return e.Err.Error() }
|
|
|
|
func (e *HintError) Unwrap() error { return e.Err }
|
|
|
|
// WithHint returns err with an explanation and a Try line (several lines are
|
|
// allowed); "{bin}" in try is replaced by how the binary is called. A nil err
|
|
// stays nil.
|
|
func WithHint(err error, explanation, try string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return &HintError{Err: err, Explanation: explanation, Try: try}
|
|
}
|
|
|
|
// ErrReported marks an error whose details the command already printed:
|
|
// the error block prints nothing and the exit code is 1.
|
|
var ErrReported = errors.New("bonfire: error already reported")
|
|
|
|
type reportedError struct {
|
|
err error
|
|
}
|
|
|
|
func (e *reportedError) Error() string { return e.err.Error() }
|
|
|
|
func (e *reportedError) Unwrap() error { return e.err }
|
|
|
|
func (e *reportedError) Is(target error) bool { return target == ErrReported }
|
|
|
|
// Reported returns an error that matches both ErrReported and err, for a
|
|
// command that has already printed its problems. A nil err gives
|
|
// ErrReported.
|
|
func Reported(err error) error {
|
|
if err == nil {
|
|
return ErrReported
|
|
}
|
|
return &reportedError{err: err}
|
|
}
|
|
|
|
// RenderError writes err to w as the console error block: the ERROR badge
|
|
// and the message, the explanation and the Try lines of a HintError ({bin}
|
|
// replaced by summer or bin/<name>, for the root named name), then "Run with
|
|
// -v for details." at verbosity 0 or the wrapped error chain under "Caused
|
|
// by:" at -v and above. cobra's unknown command error becomes `Command "x"
|
|
// is not defined.` with its suggestions as Try lines, and other usage errors
|
|
// suggest the command's help. Every message passes through sunscreen.Scrub.
|
|
// Errors wrapped by Reported print nothing.
|
|
func RenderError(w io.Writer, name string, err error, g Globals) {
|
|
if err == nil || w == nil || errors.Is(err, ErrReported) {
|
|
return
|
|
}
|
|
if cw, ok := w.(*colorprofile.Writer); ok {
|
|
w = cw.Forward
|
|
}
|
|
bin := usageName(name)
|
|
p := DefaultPalette()
|
|
message := err.Error()
|
|
explanation, try := "", ""
|
|
var hint *HintError
|
|
if errors.As(err, &hint) {
|
|
explanation, try = hint.Explanation, hint.Try
|
|
}
|
|
if strings.HasPrefix(message, "unknown command") {
|
|
cmdName, suggestions := parseUnknownCommand(message)
|
|
message = `Command "` + cmdName + `" is not defined.`
|
|
if try == "" {
|
|
var lines []string
|
|
for _, s := range suggestions {
|
|
lines = append(lines, "{bin} "+s)
|
|
}
|
|
if len(lines) == 0 {
|
|
lines = []string{"{bin} list"}
|
|
}
|
|
try = strings.Join(lines, "\n")
|
|
}
|
|
} else if try == "" {
|
|
var ue *UsageError
|
|
if errors.As(err, &ue) {
|
|
if ue.command != "" {
|
|
try = "{bin} help " + ue.command
|
|
} else {
|
|
try = "{bin} list"
|
|
}
|
|
}
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString("\n")
|
|
for i, line := range strings.Split(sunscreen.Scrub(strings.TrimRight(message, "\n")), "\n") {
|
|
if i == 0 {
|
|
b.WriteString(" " + p.ErrorBold.Reverse(true).Render(" ERROR ") + " " + line + "\n")
|
|
continue
|
|
}
|
|
b.WriteString(" " + line + "\n")
|
|
}
|
|
if explanation != "" {
|
|
b.WriteString("\n")
|
|
for _, line := range strings.Split(sunscreen.Scrub(explanation), "\n") {
|
|
b.WriteString(" " + line + "\n")
|
|
}
|
|
}
|
|
if try != "" {
|
|
b.WriteString("\n " + p.Header.Render("Try:") + "\n")
|
|
for _, line := range strings.Split(strings.ReplaceAll(try, "{bin}", bin), "\n") {
|
|
b.WriteString(" " + p.Success.Render(sunscreen.Scrub(line)) + "\n")
|
|
}
|
|
}
|
|
if g.Verbosity > 0 {
|
|
if chain := causes(err, err.Error()); len(chain) > 0 {
|
|
b.WriteString("\n " + p.Header.Render("Caused by:") + "\n")
|
|
for _, c := range chain {
|
|
b.WriteString(" " + p.Muted.Render("- "+sunscreen.Scrub(c)) + "\n")
|
|
}
|
|
}
|
|
} else {
|
|
b.WriteString("\n " + p.Muted.Render("Run with -v for details.") + "\n")
|
|
}
|
|
b.WriteString("\n")
|
|
_ = 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) {
|
|
name := ""
|
|
if i := strings.Index(msg, `"`); i >= 0 {
|
|
if j := strings.Index(msg[i+1:], `"`); j >= 0 {
|
|
name = msg[i+1 : i+1+j]
|
|
}
|
|
}
|
|
var suggestions []string
|
|
if _, rest, ok := strings.Cut(msg, "Did you mean this?"); ok {
|
|
for _, line := range strings.Split(rest, "\n") {
|
|
if s := strings.TrimSpace(line); s != "" {
|
|
suggestions = append(suggestions, s)
|
|
}
|
|
}
|
|
}
|
|
return name, suggestions
|
|
}
|
|
|
|
// causes walks the wrapped errors below err (errors.Join members expanded)
|
|
// and returns each distinct message other than top.
|
|
func causes(err error, top string) []string {
|
|
var out []string
|
|
seen := map[string]bool{top: true}
|
|
var walk func(error)
|
|
walk = func(e error) {
|
|
for e != nil {
|
|
if msg := e.Error(); !seen[msg] {
|
|
seen[msg] = true
|
|
out = append(out, msg)
|
|
}
|
|
if joined, ok := e.(interface{ Unwrap() []error }); ok {
|
|
for _, m := range joined.Unwrap() {
|
|
walk(m)
|
|
}
|
|
return
|
|
}
|
|
e = errors.Unwrap(e)
|
|
}
|
|
}
|
|
walk(errors.Unwrap(err))
|
|
if joined, ok := err.(interface{ Unwrap() []error }); ok {
|
|
for _, m := range joined.Unwrap() {
|
|
walk(m)
|
|
}
|
|
}
|
|
return out
|
|
}
|