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) }