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:
@@ -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
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user