feat(14.3-01): add summer new with a starter route and relative go.work
- build.NewApp writes sm-<name>-app: go.mod with a relative framework
replace, go.work, summer.yaml, config/ and a starter plugin
- make:plugin gains an opt-in starter route (GET /{$}) in the same
routes.go template; plain make:plugin output is unchanged
- plugin:add writes go.work use paths relative to the go.work directory
- bonfire accepts new as a bare tool command name
This commit is contained in:
@@ -34,6 +34,7 @@ func toolCommands() []bonfire.Command {
|
||||
makeJobCommand(),
|
||||
makeAdminControllerCommand(),
|
||||
addPluginCommand(),
|
||||
newCommand(),
|
||||
devCommand(),
|
||||
parityProxyCommand(),
|
||||
parityRecordCommand(),
|
||||
@@ -297,3 +298,35 @@ func addPluginCommand() bonfire.Command {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "new",
|
||||
Description: "Create a SummerCMS application in sm-<name>-app",
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "name",
|
||||
Description: "Application name: lower-case letters and digits, starting with a letter; not a Go keyword, main or internal",
|
||||
Required: true,
|
||||
}},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "framework", Description: "Path to the framework checkout"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
name, ok := in.Argument("name")
|
||||
if !ok || name == "" {
|
||||
return fmt.Errorf("new requires an application name")
|
||||
}
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
framework, _ := in.Flag("framework")
|
||||
_, err = build.NewApp(ctx, build.NewOptions{
|
||||
Name: name,
|
||||
Parent: dir,
|
||||
Framework: framework,
|
||||
}, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) {
|
||||
for _, c := range toolCommands() {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "parity:upstream", "docs:build", "docs:sync", "docs:serve"} {
|
||||
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "new", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "parity:upstream", "docs:build", "docs:sync", "docs:serve"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("missing %s in %v", want, names)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func TestToolCommandNames(t *testing.T) {
|
||||
|
||||
helpWants := map[string][]string{
|
||||
"make:plugin": {"vendor.plugin"},
|
||||
"new": {"--framework", "name"},
|
||||
"make:model": {"[plugin] [name]", "no-migration"},
|
||||
"make:migration": {"[plugin] [name]"},
|
||||
"make:command": {"[plugin] [name]"},
|
||||
|
||||
212
internal/build/new.go
Normal file
212
internal/build/new.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/token"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// appNameRe is the public `summer new <name>` rule: lower-case letters and
|
||||
// digits, starting with a letter. The name is also the second part of the
|
||||
// starter plugin ID golem15.<name>.
|
||||
var appNameRe = regexp.MustCompile(`^[a-z][a-z0-9]*$`)
|
||||
|
||||
// NewOptions configures NewApp.
|
||||
type NewOptions struct {
|
||||
// Name is the application name; the app is written to sm-<Name>-app.
|
||||
Name string
|
||||
// Parent is the directory that receives sm-<Name>-app. Empty means the
|
||||
// current directory.
|
||||
Parent string
|
||||
// Framework is the path to the framework checkout, resolved against
|
||||
// Parent when relative.
|
||||
Framework string
|
||||
}
|
||||
|
||||
// genFile is one file NewApp writes itself, relative to the app root.
|
||||
type genFile struct {
|
||||
Rel string
|
||||
Body []byte
|
||||
Mode os.FileMode
|
||||
}
|
||||
|
||||
// newData feeds the new/* templates.
|
||||
type newData struct {
|
||||
Name string
|
||||
Module string
|
||||
Framework string
|
||||
GoVersion string
|
||||
Toolchain string
|
||||
Replace string
|
||||
}
|
||||
|
||||
// ValidateAppName reports whether name is usable as `summer new <name>`.
|
||||
func ValidateAppName(name string) error {
|
||||
if !appNameRe.MatchString(name) {
|
||||
return fmt.Errorf("build: app name %q must be lower-case letters and digits, starting with a letter", name)
|
||||
}
|
||||
if token.IsKeyword(name) {
|
||||
return fmt.Errorf("build: app name %q is a Go keyword; the starter plugin package would not compile", name)
|
||||
}
|
||||
switch name {
|
||||
case "main":
|
||||
return fmt.Errorf("build: app name %q is reserved: plugins/main would be a program, not an importable package", name)
|
||||
case "internal":
|
||||
return fmt.Errorf("build: app name %q is reserved: Go forbids importing plugins/internal from the app", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appModulePath is the module path of a generated application.
|
||||
func appModulePath(name string) string {
|
||||
return "git.golem15.com/golem15/sm-" + name + "-app"
|
||||
}
|
||||
|
||||
// appDirName is the directory a generated application lives in.
|
||||
func appDirName(name string) string {
|
||||
return "sm-" + name + "-app"
|
||||
}
|
||||
|
||||
// NewApp writes a ready-to-run application to <Parent>/sm-<Name>-app and
|
||||
// returns its path. It refuses a target that exists in any form.
|
||||
func NewApp(ctx context.Context, opts NewOptions, out io.Writer) (string, error) {
|
||||
if out == nil {
|
||||
out = io.Discard
|
||||
}
|
||||
if err := ValidateAppName(opts.Name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
parent := opts.Parent
|
||||
if parent == "" {
|
||||
parent = "."
|
||||
}
|
||||
parent, err := filepath.Abs(parent)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build: resolve parent directory: %w", err)
|
||||
}
|
||||
target := filepath.Join(parent, appDirName(opts.Name))
|
||||
if _, err := os.Lstat(target); err == nil {
|
||||
return "", fmt.Errorf("build: %s already exists; choose another name or remove it", target)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("build: stat %s: %w", target, err)
|
||||
}
|
||||
if opts.Framework == "" {
|
||||
return "", fmt.Errorf("build: framework checkout not found; pass --framework <path>")
|
||||
}
|
||||
framework := opts.Framework
|
||||
if !filepath.IsAbs(framework) {
|
||||
framework = filepath.Join(parent, framework)
|
||||
}
|
||||
framework = filepath.Clean(framework)
|
||||
if err := checkFramework(framework); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
files, err := planNewApp(opts, target, framework)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Mkdir(target, 0o755); err != nil {
|
||||
return "", fmt.Errorf("build: create %s: %w", target, err)
|
||||
}
|
||||
if err := populateApp(ctx, opts, target, files); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(out, "created %s\n", target)
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// checkFramework requires dir/go.mod to declare the framework module.
|
||||
func checkFramework(dir string) error {
|
||||
module, _, err := parseGoMod(filepath.Join(dir, "go.mod"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build: framework checkout %s: %w", dir, err)
|
||||
}
|
||||
if module != frameworkModule {
|
||||
return fmt.Errorf("build: %s declares module %q, not the framework %s", dir, module, frameworkModule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// planNewApp renders every file NewApp writes from its own templates. It
|
||||
// reads nothing but the framework go.mod, so the output can be compared
|
||||
// byte for byte.
|
||||
func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) {
|
||||
if err := ValidateAppName(opts.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkFramework(framework); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
replace, err := relModulePath(target, framework)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build: framework replace: %w", err)
|
||||
}
|
||||
data := newData{
|
||||
Name: opts.Name,
|
||||
Module: appModulePath(opts.Name),
|
||||
Framework: frameworkModule,
|
||||
GoVersion: goVersionLine,
|
||||
Toolchain: toolchainLine,
|
||||
Replace: replace,
|
||||
}
|
||||
plan := []struct {
|
||||
rel string
|
||||
tmpl string
|
||||
mode os.FileMode
|
||||
}{
|
||||
{"go.mod", "new/go.mod", 0o644},
|
||||
{"config/app.yaml", "new/config/app.yaml", 0o644},
|
||||
{"config/database.yaml", "new/config/database.yaml", 0o644},
|
||||
{"config/http.yaml", "new/config/http.yaml", 0o644},
|
||||
{"config/storage.yaml", "new/config/storage.yaml", 0o644},
|
||||
}
|
||||
files := make([]genFile, 0, len(plan))
|
||||
for _, p := range plan {
|
||||
body, err := renderStub(p.tmpl, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, genFile{Rel: p.rel, Body: body, Mode: p.mode})
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// populateApp writes the planned files, the workspace, the manifest and the
|
||||
// starter plugin into target, then generates main.go and plugins.gen.go.
|
||||
func populateApp(ctx context.Context, opts NewOptions, target string, files []genFile) error {
|
||||
for _, f := range files {
|
||||
path := filepath.Join(target, filepath.FromSlash(f.Rel))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("build: mkdir %s: %w", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, f.Body, f.Mode); err != nil {
|
||||
return fmt.Errorf("build: write %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
// The app's own go.work comes before the plugin step so a go.work in an
|
||||
// ancestor directory is never picked up.
|
||||
if err := writeGoWork(filepath.Join(target, "go.work")); err != nil {
|
||||
return err
|
||||
}
|
||||
manifestPath := filepath.Join(target, manifestName)
|
||||
if err := SaveManifest(manifestPath, Manifest{Module: appModulePath(opts.Name), Binary: opts.Name}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := makePlugin(ctx, target, "golem15."+opts.Name, pluginOptions{StarterRoute: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := AddPlugin(ctx, target, filepath.Join("plugins", opts.Name)); err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := LoadManifest(manifestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return generate(target, m)
|
||||
}
|
||||
72
internal/build/new_test.go
Normal file
72
internal/build/new_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewAppSmoke writes an app next to a symlinked framework checkout,
|
||||
// builds it and lists the starter route.
|
||||
func TestNewAppSmoke(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("runs go mod tidy and go build")
|
||||
}
|
||||
repo, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent := t.TempDir()
|
||||
fw := filepath.Join(parent, "summercms")
|
||||
if err := os.Symlink(repo, fw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := t.Context()
|
||||
var log bytes.Buffer
|
||||
dir, err := NewApp(ctx, NewOptions{Name: "demo", Parent: parent, Framework: fw}, &log)
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp: %v\n%s", err, log.String())
|
||||
}
|
||||
if want := filepath.Join(parent, "sm-demo-app"); dir != want {
|
||||
t.Fatalf("dir = %s, want %s", dir, want)
|
||||
}
|
||||
|
||||
mod, err := os.ReadFile(filepath.Join(dir, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(mod, []byte("replace git.golem15.com/golem15/summercms => ../summercms")) {
|
||||
t.Fatalf("go.mod lacks the relative framework replace:\n%s", mod)
|
||||
}
|
||||
work, err := os.ReadFile(filepath.Join(dir, "go.work"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(work, []byte("./plugins/demo")) {
|
||||
t.Fatalf("go.work lacks ./plugins/demo:\n%s", work)
|
||||
}
|
||||
for _, line := range strings.Split(string(work), "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "/") {
|
||||
t.Fatalf("go.work has an absolute path %q:\n%s", line, work)
|
||||
}
|
||||
}
|
||||
|
||||
var buildOut bytes.Buffer
|
||||
if err := App(ctx, dir, &buildOut); err != nil {
|
||||
t.Fatalf("build: %v\n%s", err, buildOut.String())
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, filepath.Join(dir, "bin", "demo"), "route:list")
|
||||
cmd.Dir = dir
|
||||
listed, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("route:list: %v\n%s", err, listed)
|
||||
}
|
||||
for _, want := range []string{"/{$}", "golem15.demo"} {
|
||||
if !bytes.Contains(listed, []byte(want)) {
|
||||
t.Fatalf("route:list lacks %q:\n%s", want, listed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,14 @@ type stubData struct {
|
||||
GoVersion string
|
||||
Toolchain string
|
||||
Replace string
|
||||
// StarterRoute renders routes.go with a GET / route (summer new).
|
||||
StarterRoute bool
|
||||
}
|
||||
|
||||
// pluginOptions tunes the scaffold makePlugin writes. The zero value is the
|
||||
// plain make:plugin output.
|
||||
type pluginOptions struct {
|
||||
StarterRoute bool
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -50,6 +58,10 @@ const (
|
||||
|
||||
// MakePlugin scaffolds a compiling plugin module under plugins/<name>.
|
||||
func MakePlugin(ctx context.Context, startDir, id string) (string, error) {
|
||||
return makePlugin(ctx, startDir, id, pluginOptions{})
|
||||
}
|
||||
|
||||
func makePlugin(ctx context.Context, startDir, id string, opts pluginOptions) (string, error) {
|
||||
if err := ValidatePluginID(id); err != nil {
|
||||
return "", fmt.Errorf("build: %w", err)
|
||||
}
|
||||
@@ -89,6 +101,8 @@ func MakePlugin(ctx context.Context, startDir, id string) (string, error) {
|
||||
GoVersion: goVersionLine,
|
||||
Toolchain: toolchainLine,
|
||||
Replace: replace,
|
||||
|
||||
StarterRoute: opts.StarterRoute,
|
||||
}
|
||||
if err := writePluginScaffold(pluginDir, data); err != nil {
|
||||
return "", err
|
||||
@@ -319,14 +333,10 @@ func editAppModule(ctx context.Context, appDir, pluginDir, modPath string) error
|
||||
if err := runGo(ctx, appDir, "mod", "edit", "-require="+modPath+"@v0.0.0"); err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(appDir, pluginDir)
|
||||
rel, err := relModulePath(appDir, pluginDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build: plugin replace: %w", err)
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel != ".." && !strings.HasPrefix(rel, "../") && !filepath.IsAbs(rel) && !strings.HasPrefix(rel, "./") {
|
||||
rel = "./" + rel
|
||||
}
|
||||
if err := runGo(ctx, appDir, "mod", "edit", "-replace="+modPath+"="+rel); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -342,13 +352,13 @@ func ensureGoWork(ctx context.Context, appDir string, m Manifest, pluginDir stri
|
||||
gw = filepath.Join(appDir, "go.work")
|
||||
for _, p := range m.Plugins {
|
||||
if dir := localModuleDir(appDir, p.Module); dir != "" && dir != pluginDir {
|
||||
if err := runGoWorkUse(ctx, appDir, gw, dir); err != nil {
|
||||
if err := runGoWorkUse(ctx, gw, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return runGoWorkUse(ctx, appDir, gw, pluginDir)
|
||||
return runGoWorkUse(ctx, gw, pluginDir)
|
||||
}
|
||||
|
||||
func writeGoWork(path string) error {
|
||||
@@ -359,9 +369,32 @@ func writeGoWork(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGoWorkUse(ctx context.Context, appDir, goWork, dir string) error {
|
||||
cmd := exec.CommandContext(ctx, "go", "work", "use", dir)
|
||||
cmd.Dir = appDir
|
||||
// relModulePath returns target relative to base as a slash-separated local
|
||||
// module path: "./x", "../x", "." or "..". Go reads a replace or use path
|
||||
// without a ./ or ../ prefix as a module path, so the prefix is required.
|
||||
func relModulePath(base, target string) (string, error) {
|
||||
rel, err := filepath.Rel(base, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel != "." && rel != ".." && !strings.HasPrefix(rel, "../") && !filepath.IsAbs(rel) && !strings.HasPrefix(rel, "./") {
|
||||
rel = "./" + rel
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// runGoWorkUse adds dir to goWork with a path relative to the go.work
|
||||
// directory, so a committed go.work never carries a machine-specific
|
||||
// absolute path.
|
||||
func runGoWorkUse(ctx context.Context, goWork, dir string) error {
|
||||
workDir := filepath.Dir(goWork)
|
||||
arg := dir
|
||||
if rel, err := relModulePath(workDir, dir); err == nil {
|
||||
arg = rel
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "go", "work", "use", arg)
|
||||
cmd.Dir = workDir
|
||||
applyGoWorkFile(cmd, goWork)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
35
internal/build/stubs/new.tmpl
Normal file
35
internal/build/stubs/new.tmpl
Normal file
@@ -0,0 +1,35 @@
|
||||
{{define "new/go.mod"}}module {{.Module}}
|
||||
|
||||
{{.GoVersion}}
|
||||
|
||||
{{.Toolchain}}
|
||||
|
||||
require {{.Framework}} v0.0.0
|
||||
|
||||
replace {{.Framework}} => {{.Replace}}
|
||||
{{end}}
|
||||
|
||||
{{define "new/config/app.yaml"}}name: {{.Name}}
|
||||
debug: false
|
||||
# The application key encrypts data at rest. Set SUMMER_APP__KEY in .env (a
|
||||
# 32-byte base64 value); keep it out of this file and out of git.
|
||||
key: ""
|
||||
{{end}}
|
||||
|
||||
{{define "new/config/database.yaml"}}# Set SUMMER_DATABASE__DSN in .env; compose.yaml runs the matching PostgreSQL.
|
||||
dsn: ""
|
||||
{{end}}
|
||||
|
||||
{{define "new/config/http.yaml"}}body_limits:
|
||||
# Request body limits in bytes. Keep them YAML numbers: values from the
|
||||
# environment are strings and are rejected at boot.
|
||||
default_bytes: 1048576
|
||||
upload_bytes: 10485760
|
||||
{{end}}
|
||||
|
||||
{{define "new/config/storage.yaml"}}# Uploads bucket, required by summer serve. A file:// URL is a local
|
||||
# directory under the application working directory.
|
||||
uploads:
|
||||
bucket_url: "file://./storage/app/uploads"
|
||||
public_path_prefix: "/storage/uploads"
|
||||
{{end}}
|
||||
@@ -61,13 +61,35 @@ func init() {
|
||||
{{end}}
|
||||
|
||||
{{define "routes.go"}}package {{.Package}}
|
||||
{{if .StarterRoute}}
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
var _ pact.HasRoutes = (*Plugin)(nil)
|
||||
|
||||
// Routes registers the starter route: GET / answers with a JSON greeting
|
||||
// naming this plugin.
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Get("/{$}", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Hello from SummerCMS",
|
||||
"plugin": p.ID(),
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
{{else}}
|
||||
import "git.golem15.com/golem15/summercms/modules/pact"
|
||||
|
||||
var _ pact.HasRoutes = (*Plugin)(nil)
|
||||
|
||||
func (p *Plugin) Routes(pact.Router) error { return nil }
|
||||
{{end}}
|
||||
{{end}}{{end}}
|
||||
|
||||
{{define "go.mod"}}module {{.Module}}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ bonfire is the console layer of SummerCMS. Plugins and framework modules describ
|
||||
## Features
|
||||
|
||||
- Command values with a description, positional arguments (`bonfire.Arg`) and string flags (`bonfire.Flag`), collected from plugins or the tool itself.
|
||||
- Command name validation: plugin commands must use the `namespace:verb` form (for example `blog:import`); `build`, `dev`, `serve` and `migrate` are the only bare names accepted. Invalid names make `bonfire.NewRoot` fail with `bonfire.ErrCommandName`.
|
||||
- Command name validation: plugin commands must use the `namespace:verb` form (for example `blog:import`); `build`, `dev`, `new`, `serve` and `migrate` are the only bare names accepted. Invalid names make `bonfire.NewRoot` fail with `bonfire.ErrCommandName`.
|
||||
- Usage strings and argument-count checks derived from the declared arguments (`<name>` for required, `[name]` for optional).
|
||||
- Scalar flags, bare flags (`bonfire.Flag.Bare`, so `--force` alone stores `true`) and ordered repeatable flags (`bonfire.Flag.Repeatable`, read back through `bonfire.Input.Flags`).
|
||||
- Styled status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream).
|
||||
|
||||
@@ -109,7 +109,7 @@ func (in cobraInput) Flag(name string) (string, bool) {
|
||||
|
||||
func validCommandName(name string) bool {
|
||||
switch name {
|
||||
case "build", "dev", "serve", "migrate":
|
||||
case "build", "dev", "new", "serve", "migrate":
|
||||
return true
|
||||
}
|
||||
ns, verb, ok := strings.Cut(name, ":")
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestValidBareRuntimeCommandNames(t *testing.T) {
|
||||
for _, name := range []string{"serve", "migrate", "migrate:rollback", "migrate:status", "build", "dev"} {
|
||||
for _, name := range []string{"serve", "migrate", "migrate:rollback", "migrate:status", "build", "dev", "new"} {
|
||||
if _, err := NewRoot("app", []Command{{Name: name}}, io.Discard); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user