package build import ( "bytes" "context" "crypto/rand" "encoding/base64" "errors" "fmt" "go/token" "io" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" ) // 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. Empty means auto-detection: a sibling // summercms or summercms.go checkout, then the source tree this binary // was built from. Framework string // NoPlugin skips the starter plugin; summer.yaml then lists no plugins. NoPlugin bool // AppKey is written to .env as SUMMER_APP__KEY. It must be the base64 // (standard encoding) of 32 bytes; the CLI passes lagoon.GenerateAppKey. AppKey string // AdminSecret is written to .env as SUMMER_ADMIN__JWT__SECRET, the HMAC // secret of admin tokens (admin.jwt.secret). Empty means NewApp // generates the base64 of 32 crypto/rand bytes. AdminSecret string // NoGit skips git init and the first commit. NoGit bool } // Local development database written to compose.yaml and .env. The port is // not 5432 so it does not clash with a PostgreSQL already on the host, and // it is published on the loopback address only. const ( devDBUser = "summer" devDBPassword = "summer" devDBHostPort = "127.0.0.1:54320" ) // Fallback identity for the first commit when git has none configured. const ( fallbackGitName = "SummerCMS" fallbackGitEmail = "summer@localhost" ) // frameworkSiblings are the directory names auto-detection tries next to // the new application. A candidate counts only when its go.mod declares the // framework module. var frameworkSiblings = []string{"summercms", "summercms.go"} // frameworkFromBinary returns the framework checkout this binary was built // from, or "" when unknown. Tests replace it. var frameworkFromBinary = binarySourceFramework // genFile is one file NewApp writes itself, relative to the app root. A // non-empty Link makes it a symlink to Link (relative to the file) instead. type genFile struct { Rel string Body []byte Mode os.FileMode Link string } // newData feeds the new/* templates. type newData struct { Name string Module string Framework string GoVersion string Toolchain string Replace string DSN string AppKey string AdminSecret string NoPlugin bool } // 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 } if err := validateAppKey(opts.AppKey); err != nil { return "", err } if opts.AdminSecret == "" { secret, err := generateAdminSecret() if err != nil { return "", fmt.Errorf("build: generate admin secret: %w", err) } opts.AdminSecret = secret } if err := validateAdminSecret(opts.AdminSecret); 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) } if st, err := os.Stat(parent); err != nil { return "", fmt.Errorf("build: parent directory: %w", err) } else if !st.IsDir() { return "", fmt.Errorf("build: parent %s is not a directory", parent) } 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) } framework, err := resolveFramework(parent, opts.Framework) if err != nil { return "", err } if underRoot(framework, target) { return "", fmt.Errorf("build: %s is inside the framework checkout %s; run summer new in the directory next to the checkout", target, framework) } 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) } // target did not exist before this run, so a failure removes it and a // re-run with the same name is not blocked. if err := populateApp(ctx, opts, target, files); err != nil { if rmErr := os.RemoveAll(target); rmErr != nil { return "", fmt.Errorf("build: create %s: %w (removing it failed: %v)", target, err, rmErr) } return "", fmt.Errorf("build: create %s (removed it): %w", target, err) } if !opts.NoGit { initGit(ctx, target, opts.Name, out) } fmt.Fprintf(out, "created %s\n", target) next, err := renderStub("new/next-steps", newData{Name: opts.Name, NoPlugin: opts.NoPlugin}) if err != nil { return target, err } _, _ = out.Write(next) return target, nil } // validateAppKey requires the base64 (standard encoding) of 32 bytes, the // form lagoon accepts for app.key. func validateAppKey(key string) error { raw, err := base64.StdEncoding.DecodeString(key) if key == "" || err != nil || len(raw) != 32 { return fmt.Errorf("build: application key must be the base64 of 32 bytes") } return nil } // adminSecretRe is the character set an admin secret may use: it is written // unquoted into .env, whose reader trims whitespace and strips quotes. var adminSecretRe = regexp.MustCompile(`^[A-Za-z0-9+/=_-]{32,}$`) // generateAdminSecret returns the base64 (standard encoding) of 32 // crypto/rand bytes. func generateAdminSecret() (string, error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err } return base64.StdEncoding.EncodeToString(raw), nil } // validateAdminSecret requires at least 32 characters that are safe to // write unquoted into .env. func validateAdminSecret(s string) error { if !adminSecretRe.MatchString(s) { return fmt.Errorf("build: admin secret must be at least 32 characters of letters, digits, +, /, =, _ or -") } return nil } // devDSN is the .env database DSN matching compose.yaml. func devDSN(name string) string { return "postgres://" + devDBUser + ":" + devDBPassword + "@" + devDBHostPort + "/" + name + "?sslmode=disable" } // initGit creates the repository with one commit on main. Every failure is a // warning: the application is usable without git. func initGit(ctx context.Context, dir, name string, out io.Writer) { if _, err := exec.LookPath("git"); err != nil { fmt.Fprintf(out, "warning: git not found on PATH; skipped git init\n") return } if res, err := runGit(ctx, dir, "init", "--quiet", "--initial-branch=main"); err != nil { fmt.Fprintf(out, "warning: git init failed, the app has no repository: %v\n%s", err, res) return } if res, err := runGit(ctx, dir, "add", "-A"); err != nil { fmt.Fprintf(out, "warning: git add failed, nothing committed: %v\n%s", err, res) return } var args []string var missing []string if gitIdentityMissing(ctx, dir, "user.name", "GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME") { args = append(args, "-c", "user.name="+fallbackGitName) missing = append(missing, "user.name") } if gitIdentityMissing(ctx, dir, "user.email", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL") { args = append(args, "-c", "user.email="+fallbackGitEmail) missing = append(missing, "user.email") } if len(missing) > 0 { fmt.Fprintf(out, "warning: git %s not configured; the first commit uses %s <%s>\n", strings.Join(missing, " and "), fallbackGitName, fallbackGitEmail) } args = append(args, "commit", "--quiet", "-m", "Create "+appDirName(name)+" with summer new") if res, err := runGit(ctx, dir, args...); err != nil { fmt.Fprintf(out, "warning: git commit failed; the files are staged but not committed: %v\n%s", err, res) } } // gitIdentityMissing reports whether key is unset in git config and none of // the environment variables that supply it are set. func gitIdentityMissing(ctx context.Context, dir, key string, envs ...string) bool { for _, e := range envs { if os.Getenv(e) != "" { return false } } res, err := runGit(ctx, dir, "config", key) return err != nil || len(bytes.TrimSpace(res)) == 0 } // runGit runs git in dir with stdin closed and terminal prompts disabled. func runGit(ctx context.Context, dir string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir cmd.Stdin = nil cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") return cmd.CombinedOutput() } // resolveFramework picks the framework checkout: the explicit flag (never // falling through when it is wrong), then a sibling of parent whose go.mod // declares the framework module, then the source tree of this binary. Paths // stay lexical so a symlinked sibling yields a ../ replace. func resolveFramework(parent, flag string) (string, error) { if flag != "" { dir := flag if !filepath.IsAbs(dir) { dir = filepath.Join(parent, dir) } dir = filepath.Clean(dir) if err := checkFramework(dir); err != nil { return "", fmt.Errorf("build: --framework %s: %w", flag, err) } return dir, nil } for _, name := range frameworkSiblings { dir := filepath.Join(parent, name) if checkFramework(dir) == nil { return dir, nil } } if dir := frameworkFromBinary(); dir != "" && checkFramework(dir) == nil { return dir, nil } return "", fmt.Errorf("build: no framework checkout found: looked for %s next to %s; pass --framework ", strings.Join(frameworkSiblings, " and "), parent) } // binarySourceFramework returns the repository root this file was compiled // from. A -trimpath build records a module-relative path, which is ignored. func binarySourceFramework() string { _, file, _, ok := runtime.Caller(0) if !ok || !filepath.IsAbs(file) { return "" } // file is /internal/build/new.go. root := filepath.Dir(filepath.Dir(filepath.Dir(file))) if checkFramework(root) != nil { return "" } return root } // 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("framework checkout %s: %w", dir, err) } if module != frameworkModule { return fmt.Errorf("%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 := validateAppKey(opts.AppKey); err != nil { return nil, err } if err := validateAdminSecret(opts.AdminSecret); err != nil { return nil, err } if err := checkFramework(framework); err != nil { return nil, fmt.Errorf("build: %w", 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, DSN: devDSN(opts.Name), AppKey: opts.AppKey, AdminSecret: opts.AdminSecret, NoPlugin: opts.NoPlugin, } 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}, {".env", "new/env", 0o600}, {".env.example", "new/env.example", 0o644}, {"compose.yaml", "new/compose.yaml", 0o644}, {".gitignore", "new/gitignore", 0o644}, {"CLAUDE.md", "new/CLAUDE.md", 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}) } // One source for agent instructions; a relative symlink survives git // clone on Linux and macOS. files = append(files, genFile{Rel: "AGENTS.md", Link: "CLAUDE.md"}) 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 f.Link != "" { if err := os.Symlink(f.Link, path); err != nil { return fmt.Errorf("build: symlink %s: %w", path, err) } continue } if err := os.WriteFile(path, f.Body, f.Mode); err != nil { return fmt.Errorf("build: write %s: %w", path, err) } // WriteFile honours the umask; set the mode exactly (.env is 0600). if err := os.Chmod(path, f.Mode); err != nil { return fmt.Errorf("build: chmod %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 !opts.NoPlugin { if err := addStarterPlugin(ctx, opts.Name, target); err != nil { return err } } m, err := LoadManifest(manifestPath) if err != nil { return err } return generate(target, m) } // addStarterPlugin creates golem15. through the make:plugin and // plugin:add code paths, with the starter route. func addStarterPlugin(ctx context.Context, name, target string) error { if _, err := makePlugin(ctx, target, "golem15."+name, pluginOptions{StarterRoute: true}); err != nil { return err } return AddPlugin(ctx, target, filepath.Join("plugins", name)) }