- NewOptions.AdminSecret: summer new writes SUMMER_ADMIN__JWT__SECRET to .env (still 0600), generated from 32 crypto/rand bytes when empty and validated as at least 32 .env-safe characters - .env.example gets an empty SUMMER_ADMIN__JWT__SECRET= placeholder with a comment; the secret never reaches the printed next steps - The next steps and the generated CLAUDE.md explain the admin at /backend: make:admin-controller, build, migrate and summer admin:create --email you@example.com --superuser - Goldens use a fixed, non-secret admin secret fixture - Scaffolding, installation and users-and-permissions docs updated
463 lines
15 KiB
Go
463 lines
15 KiB
Go
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 <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. 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 <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
|
|
}
|
|
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 ../<sibling> 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 <path>",
|
|
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 <root>/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.<name> 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))
|
|
}
|