feat(summer): help, examples and hints for every tool command

- build runs go build under a spinner on a terminal and ends with
  'Built bin/<name> in <time>'; make:* print 'Created <path>' per file
- make:*, plugin:add, new, parity:*, docs:build, docs:sync and every
  delegate get long help and examples; delegates note that global options
  pass through to the application binary
- make:* failures (no summer.yaml, invalid plugin ID or name, existing
  artifact, unknown plugin) explain themselves and suggest a command
- docs:build and docs:sync return bonfire.Reported after the problem list
- TestCommandsDocumented covers the tool commands too
- docs: Upgrading to 0.4, delegates and global options, make: results and
  hints, the checklist for a finished command
This commit is contained in:
Jakub Zych
2026-10-21 13:25:53 +02:00
parent 9d06a78f62
commit 33460607db
14 changed files with 385 additions and 50 deletions

View File

@@ -10,6 +10,8 @@ import (
"time"
"unicode"
"unicode/utf8"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
// ArtifactResult is the outcome of a make:* command.
@@ -46,7 +48,7 @@ func MakeModel(ctx context.Context, startDir, pluginID, name string, noMigration
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: model: %w", err)
return ArtifactResult{}, identHint(fmt.Errorf("build: model: %w", err))
}
table, err := modelTableName(plugin.ID, name)
if err != nil {
@@ -125,7 +127,7 @@ func MakeMigration(ctx context.Context, startDir, pluginID, name string) (Artifa
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: migration: %w", err)
return ArtifactResult{}, identHint(fmt.Errorf("build: migration: %w", err))
}
if err := rejectDuplicateIdent(plugin.Dir, "updates", name); err != nil {
return ArtifactResult{}, err
@@ -163,7 +165,7 @@ func MakeCommand(ctx context.Context, startDir, pluginID, name string) (Artifact
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: command: %w", err)
return ArtifactResult{}, identHint(fmt.Errorf("build: command: %w", err))
}
fn := name + "Command"
if err := rejectDuplicateIdent(plugin.Dir, "console", fn); err != nil {
@@ -201,7 +203,7 @@ func MakeJob(ctx context.Context, startDir, pluginID, name string) (ArtifactResu
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: job: %w", err)
return ArtifactResult{}, identHint(fmt.Errorf("build: job: %w", err))
}
fn := name + "Job"
if err := rejectDuplicateIdent(plugin.Dir, "jobs", fn); err != nil {
@@ -237,7 +239,7 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: admin-controller: %w", err)
return ArtifactResult{}, identHint(fmt.Errorf("build: admin-controller: %w", err))
}
fn := name + "Controller"
if err := rejectDuplicateIdent(plugin.Dir, "controllers", fn); err != nil {
@@ -325,6 +327,35 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
return ArtifactResult{PluginDir: plugin.Dir, Files: created, Hint: plugin.Hint}, nil
}
// pluginIDHint explains an invalid plugin ID.
func pluginIDHint(err error) error {
return bonfire.WithHint(err,
"A plugin ID is the vendor and the plugin name in lower case letters and digits, joined by a dot.",
"{bin} "+cliCommand("make:model")+" acme.blog <Name>")
}
// identHint explains an invalid artifact name.
func identHint(err error) error {
return bonfire.WithHint(err,
"The name becomes an exported Go identifier: a capital letter, then letters and digits.",
"{bin} "+cliCommand("make:model")+" <vendor.plugin> BlogPost")
}
// existsHint explains an artifact that is already there.
func existsHint(err error) error {
return bonfire.WithHint(err,
"summer make: never overwrites a file or a name the plugin already has; choose another name, or edit the existing one.",
"{bin} "+cliCommand("make:model")+" <vendor.plugin> <OtherName>")
}
// pluginMissingHint explains a plugin that summer.yaml does not list and
// plugins/ does not hold.
func pluginMissingHint(err error, id string) error {
return bonfire.WithHint(err,
"The plugin is not listed in summer.yaml and has no directory under plugins/.",
"{bin} make:plugin "+id+"\n{bin} plugin:add <dir>")
}
func unexportedIdent(name string) string {
r, size := utf8.DecodeRuneInString(name)
return string(unicode.ToLower(r)) + name[size:]
@@ -348,7 +379,7 @@ func resolvePlugin(startDir, id string) (pluginRef, error) {
}
}
if err := ValidatePluginID(id); err != nil {
return pluginRef{}, fmt.Errorf("build: %w", err)
return pluginRef{}, pluginIDHint(fmt.Errorf("build: %w", err))
}
pluginDir, err := locatePluginDir(appDir, id)
if err != nil {
@@ -400,7 +431,9 @@ func inferPluginID(start, appDir string) (string, error) {
}
dir = parent
}
return "", fmt.Errorf("build: plugin id omitted and no plugin.go found from %s", start)
return "", bonfire.WithHint(fmt.Errorf("build: plugin id omitted and no plugin.go found from %s", start),
"Without a plugin ID, summer "+cliCommand("make:model")+" looks for plugin.go in this directory and above it.",
"{bin} "+cliCommand("make:model")+" <vendor.plugin> <Name>")
}
func locatePluginDir(appDir, id string) (string, error) {
@@ -425,12 +458,12 @@ func locatePluginDir(appDir, id string) (string, error) {
st, err := os.Stat(candidate)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("build: plugin %s not found", id)
return "", pluginMissingHint(fmt.Errorf("build: plugin %s not found", id), id)
}
return "", fmt.Errorf("build: stat %s: %w", candidate, err)
}
if !st.IsDir() {
return "", fmt.Errorf("build: plugin %s not found", id)
return "", pluginMissingHint(fmt.Errorf("build: plugin %s not found", id), id)
}
return candidate, nil
}
@@ -492,7 +525,7 @@ func rejectDuplicateFile(pluginDir, rel string) error {
return fmt.Errorf("build: artifact path %q escapes the plugin directory", rel)
}
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("build: %s already exists", rel)
return existsHint(fmt.Errorf("build: %s already exists", rel))
} else if !os.IsNotExist(err) {
return fmt.Errorf("build: stat %s: %w", path, err)
}
@@ -506,7 +539,7 @@ func rejectDuplicateIdent(pluginDir, pkg, ident string) error {
}
for _, ref := range refs {
if ref.Ident == ident {
return fmt.Errorf("build: %s.%s already exists", pkg, ident)
return existsHint(fmt.Errorf("build: %s.%s already exists", pkg, ident))
}
}
return nil

View File

@@ -8,6 +8,7 @@ import (
"strings"
"unicode"
"git.golem15.com/golem15/summercms/modules/bonfire"
"github.com/knadh/koanf/parsers/yaml"
)
@@ -193,6 +194,34 @@ func ValidatePluginID(id string) error {
return nil
}
// cliInvocation is the summer command line being run (os.Args without the
// program), shell-quoted, for the Try line of a hint.
func cliInvocation() string {
parts := make([]string, 0, len(os.Args))
for _, a := range os.Args[1:] {
if a == "" || strings.ContainsAny(a, " \t\n'\"\\$`*?[]{}()<>|&;#~!") {
a = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'"
}
parts = append(parts, a)
}
return strings.Join(parts, " ")
}
// cliCommand is the summer command being run (the first argument that is
// not an option), or def when there is none, for hint texts.
func cliCommand(def string) string {
args := os.Args[1:]
for i := 0; i < len(args); i++ {
switch a := args[i]; {
case a == "--env":
i++
case a != "" && !strings.HasPrefix(a, "-"):
return a
}
}
return def
}
// FindAppDir walks up from start looking for summer.yaml.
func FindAppDir(start string) (string, error) {
return findAppDir(start)
@@ -212,7 +241,9 @@ func findAppDir(start string) (string, error) {
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("build: no %s found from %s", manifestName, start)
return "", bonfire.WithHint(fmt.Errorf("build: no %s found from %s", manifestName, start),
"summer "+cliCommand("build")+" runs inside an application: a directory with summer.yaml, or one below it.",
"cd <app>; {bin} "+cliInvocation())
}
dir = parent
}

View File

@@ -15,6 +15,8 @@ import (
"strconv"
"strings"
"text/template"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
//go:embed stubs/*.tmpl
@@ -63,7 +65,9 @@ func MakePlugin(ctx context.Context, startDir, id string) (string, error) {
func makePlugin(ctx context.Context, startDir, id string, opts pluginOptions) (string, error) {
if err := ValidatePluginID(id); err != nil {
return "", fmt.Errorf("build: %w", err)
return "", bonfire.WithHint(fmt.Errorf("build: %w", err),
"A plugin ID is the vendor and the plugin name in lower case letters and digits, joined by a dot.",
"{bin} make:plugin acme.blog")
}
appDir, err := findAppDir(startDir)
if err != nil {
@@ -75,7 +79,9 @@ func makePlugin(ctx context.Context, startDir, id string, opts pluginOptions) (s
return "", fmt.Errorf("build: plugin path %q escapes the app root", pluginDir)
}
if _, err := os.Stat(pluginDir); err == nil {
return "", fmt.Errorf("build: plugin directory %s already exists", pluginDir)
return "", bonfire.WithHint(fmt.Errorf("build: plugin directory %s already exists", pluginDir),
"summer make:plugin creates a new directory under plugins/ and never overwrites one; register an existing plugin with plugin:add.",
"{bin} plugin:add "+filepath.Join("plugins", name))
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("build: stat %s: %w", pluginDir, err)
}