- 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
213 lines
6.4 KiB
Go
213 lines
6.4 KiB
Go
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)
|
|
}
|