feat(14.3-01): give summer new apps a key, compose, agent notes and a first commit

- .env (0600) with a fresh lagoon.GenerateAppKey key and the compose DSN;
  .env.example without a key
- compose.yaml runs postgres:16 on 127.0.0.1:54320 with a healthcheck
- .gitignore, CLAUDE.md with AGENTS.md as a relative symlink
- git init on main and one commit, with a fallback identity only when git
  has none; git failures are warnings; --no-git skips it
- print the next steps after creating the app

Claude-Session: https://claude.ai/code/session_01M8Qu9LgCKYy7iYEho52tW8
This commit is contained in:
Jakub Zych
2026-10-08 11:44:44 +02:00
parent ca5b2963a5
commit 147ef2494e
4 changed files with 258 additions and 2 deletions

View File

@@ -9,6 +9,7 @@ import (
"git.golem15.com/golem15/summercms/internal/build"
"git.golem15.com/golem15/summercms/internal/dev"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
func main() {
@@ -311,6 +312,7 @@ func newCommand() bonfire.Command {
Flags: []bonfire.Flag{
{Name: "framework", Description: "Path to the framework checkout (default: a sibling summercms or summercms.go checkout)"},
{Name: "no-plugin", Description: "Do not create the starter plugin", Bare: true},
{Name: "no-git", Description: "Do not initialise a git repository", Bare: true},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
name, ok := in.Argument("name")
@@ -322,11 +324,17 @@ func newCommand() bonfire.Command {
return err
}
framework, _ := in.Flag("framework")
key, err := lagoon.GenerateAppKey()
if err != nil {
return fmt.Errorf("new: generate application key: %w", err)
}
_, err = build.NewApp(ctx, build.NewOptions{
Name: name,
Parent: dir,
Framework: framework,
NoPlugin: flagTrue(in, "no-plugin"),
AppKey: key,
NoGit: flagTrue(in, "no-git"),
}, out)
return err
},

View File

@@ -1,12 +1,15 @@
package build
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"go/token"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
@@ -32,8 +35,28 @@ type NewOptions struct {
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
// 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.
@@ -43,11 +66,13 @@ var frameworkSiblings = []string{"summercms", "summercms.go"}
// from, or "" when unknown. Tests replace it.
var frameworkFromBinary = binarySourceFramework
// genFile is one file NewApp writes itself, relative to the app root.
// 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.
@@ -58,6 +83,9 @@ type newData struct {
GoVersion string
Toolchain string
Replace string
DSN string
AppKey string
NoPlugin bool
}
// ValidateAppName reports whether name is usable as `summer new <name>`.
@@ -96,6 +124,9 @@ func NewApp(ctx context.Context, opts NewOptions, out io.Writer) (string, error)
if err := ValidateAppName(opts.Name); err != nil {
return "", err
}
if err := validateAppKey(opts.AppKey); err != nil {
return "", err
}
parent := opts.Parent
if parent == "" {
parent = "."
@@ -138,10 +169,89 @@ func NewApp(ctx context.Context, opts NewOptions, out io.Writer) (string, error)
}
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
}
// 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
@@ -205,6 +315,9 @@ 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 := checkFramework(framework); err != nil {
return nil, fmt.Errorf("build: %w", err)
}
@@ -219,6 +332,9 @@ func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) {
GoVersion: goVersionLine,
Toolchain: toolchainLine,
Replace: replace,
DSN: devDSN(opts.Name),
AppKey: opts.AppKey,
NoPlugin: opts.NoPlugin,
}
plan := []struct {
rel string
@@ -230,6 +346,11 @@ func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) {
{"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 {
@@ -239,6 +360,9 @@ func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) {
}
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
}
@@ -250,9 +374,19 @@ func populateApp(ctx context.Context, opts NewOptions, target string, files []ge
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.

View File

@@ -9,6 +9,9 @@ import (
"testing"
)
// testAppKey is a fixed, valid app key (base64 of 32 bytes) for hermetic tests.
const testAppKey = "c3VtbWVyY21zLXRlc3Qta2V5LTMyLWJ5dGVzLWxvbmc="
// TestNewAppSmoke writes an app next to a symlinked framework checkout,
// builds it and lists the starter route.
func TestNewAppSmoke(t *testing.T) {
@@ -26,7 +29,7 @@ func TestNewAppSmoke(t *testing.T) {
}
ctx := t.Context()
var log bytes.Buffer
dir, err := NewApp(ctx, NewOptions{Name: "demo", Parent: parent, Framework: fw}, &log)
dir, err := NewApp(ctx, NewOptions{Name: "demo", Parent: parent, Framework: fw, AppKey: testAppKey, NoGit: true}, &log)
if err != nil {
t.Fatalf("NewApp: %v\n%s", err, log.String())
}

View File

@@ -33,3 +33,114 @@ uploads:
bucket_url: "file://./storage/app/uploads"
public_path_prefix: "/storage/uploads"
{{end}}
{{define "new/env"}}# Local secrets for this application: the database DSN and the application
# key. .gitignore excludes this file; never commit it. Real environment
# variables override the values here.
SUMMER_DATABASE__DSN={{.DSN}}
SUMMER_APP__KEY={{.AppKey}}
{{end}}
{{define "new/env.example"}}# The variables .env sets. summer new wrote a .env with a fresh key; copy this
# file to .env only when you need to start over.
SUMMER_DATABASE__DSN={{.DSN}}
# A 32-byte base64 application key. Generate one with ./bin/{{.Name}} key:generate
# and paste only the key, without the leading check mark.
SUMMER_APP__KEY=
# Uploads bucket; config/storage.yaml already sets this default.
# SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://./storage/app/uploads
# Configuration environment; selects the config overlay (default production).
# SUMMER_ENV=development
{{end}}
{{define "new/compose.yaml"}}# Local development database: docker compose up -d --wait
# PostgreSQL is published on the loopback address only, and the credentials
# match SUMMER_DATABASE__DSN in .env.
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: summer
POSTGRES_PASSWORD: summer
POSTGRES_DB: {{.Name}}
ports:
- "127.0.0.1:54320:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U summer -d {{.Name}}"]
interval: 2s
timeout: 3s
retries: 30
volumes:
pgdata:
{{end}}
{{define "new/gitignore"}}/bin/
/tmp/
/storage/
.env
go.work.sum
*.exe
{{end}}
{{define "new/CLAUDE.md"}}# CLAUDE.md
Guidance for AI coding agents working in this repository. `AGENTS.md` is a symlink to this file.
## What this is
`sm-{{.Name}}-app` is a SummerCMS application: a Go program built on the SummerCMS framework, module `{{.Framework}}`. `go.mod` replaces the framework with the local checkout at `{{.Replace}}`. That checkout is shared with every other application next to it: do not edit the framework from here. Change this application, or report the framework problem.
Framework documentation for agents:
- https://summercms.io/docs/llms.txt (index)
- https://summercms.io/docs/llms-full.txt (full text in one file)
## Commands
Run them from this directory.
```sh
docker compose up -d --wait # start PostgreSQL 16 on 127.0.0.1:54320
summer build # generate main.go and plugins.gen.go, build bin/{{.Name}}
summer dev # watch sources and rebuild
summer migrate # run plugin migrations
summer serve # start the HTTP server
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/<name>
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
```
## Layout
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
- `plugins/`: local plugin modules.{{if not .NoPlugin}} `plugins/{{.Name}}` is the starter plugin `golem15.{{.Name}}`; its `routes.go` answers `GET /` with JSON.{{end}}
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
- `.env`: local secrets (database DSN, application key). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
- `compose.yaml`: the local PostgreSQL 16.
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.
- `go.work`: the workspace that joins the application and its plugin modules.
Plugins are Go modules compiled into the binary at build time; there is no runtime plugin loading. Add a plugin with `summer make:plugin` and `summer plugin:add`, then run `summer build`.
## Naming convention
- `sm-<name>-app`: an application, like this repository.
- `sm-<name>-plugin`: a standalone plugin module.
- `sm-<name>-theme`: a Nuxt/Vue frontend.
{{end}}
{{define "new/next-steps"}}
Next steps, inside sm-{{.Name}}-app:
docker compose up -d --wait
summer build
summer migrate
summer serve
{{if .NoPlugin}}
The app has no plugins yet. Add one with summer make:plugin golem15.<name>
and summer plugin:add plugins/<name>, then run summer build again.
{{else}}
Then, from another terminal:
curl http://127.0.0.1:8080/
{{end}}{{end}}