From 7bc955a0c273a5258fd0dfdc861a5908ee0806fe Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 8 Oct 2026 11:37:53 +0200 Subject: [PATCH] feat(14.3-01): add summer new with a starter route and relative go.work - build.NewApp writes sm--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 --- cmd/summer/main.go | 33 +++++ cmd/summer/main_test.go | 3 +- internal/build/new.go | 212 +++++++++++++++++++++++++++++++ internal/build/new_test.go | 72 +++++++++++ internal/build/scaffold.go | 53 ++++++-- internal/build/stubs/new.tmpl | 35 +++++ internal/build/stubs/plugin.tmpl | 24 +++- modules/bonfire/README.md | 2 +- modules/bonfire/command.go | 2 +- modules/bonfire/command_test.go | 2 +- 10 files changed, 423 insertions(+), 15 deletions(-) create mode 100644 internal/build/new.go create mode 100644 internal/build/new_test.go create mode 100644 internal/build/stubs/new.tmpl diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 5def5f5..dc51860 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -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--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 + }, + } +} diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index 349812b..688720c 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -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]"}, diff --git a/internal/build/new.go b/internal/build/new.go new file mode 100644 index 0000000..96d34b0 --- /dev/null +++ b/internal/build/new.go @@ -0,0 +1,212 @@ +package build + +import ( + "context" + "errors" + "fmt" + "go/token" + "io" + "os" + "path/filepath" + "regexp" +) + +// appNameRe is the public `summer new ` rule: lower-case letters and +// digits, starting with a letter. The name is also the second part of the +// starter plugin ID golem15.. +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--app. + Name string + // Parent is the directory that receives sm--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 `. +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 /sm--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 ") + } + 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) +} diff --git a/internal/build/new_test.go b/internal/build/new_test.go new file mode 100644 index 0000000..932f1bc --- /dev/null +++ b/internal/build/new_test.go @@ -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) + } + } +} diff --git a/internal/build/scaffold.go b/internal/build/scaffold.go index b0e39a3..8b0983d 100644 --- a/internal/build/scaffold.go +++ b/internal/build/scaffold.go @@ -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/. 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 { diff --git a/internal/build/stubs/new.tmpl b/internal/build/stubs/new.tmpl new file mode 100644 index 0000000..b75e4be --- /dev/null +++ b/internal/build/stubs/new.tmpl @@ -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}} diff --git a/internal/build/stubs/plugin.tmpl b/internal/build/stubs/plugin.tmpl index 3807a0b..7fd05a4 100644 --- a/internal/build/stubs/plugin.tmpl +++ b/internal/build/stubs/plugin.tmpl @@ -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}} diff --git a/modules/bonfire/README.md b/modules/bonfire/README.md index e2eb87c..70ef075 100644 --- a/modules/bonfire/README.md +++ b/modules/bonfire/README.md @@ -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 (`` 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). diff --git a/modules/bonfire/command.go b/modules/bonfire/command.go index 9294782..f22e838 100644 --- a/modules/bonfire/command.go +++ b/modules/bonfire/command.go @@ -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, ":") diff --git a/modules/bonfire/command_test.go b/modules/bonfire/command_test.go index ca08969..69e6f1e 100644 --- a/modules/bonfire/command_test.go +++ b/modules/bonfire/command_test.go @@ -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) }