From 147ef2494eb1f8bb440ee9acd5ad636f80c0db56 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 8 Oct 2026 11:44:44 +0200 Subject: [PATCH] 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 --- cmd/summer/main.go | 8 ++ internal/build/new.go | 136 +++++++++++++++++++++++++++++++++- internal/build/new_test.go | 5 +- internal/build/stubs/new.tmpl | 111 +++++++++++++++++++++++++++ 4 files changed, 258 insertions(+), 2 deletions(-) diff --git a/cmd/summer/main.go b/cmd/summer/main.go index dd90bdd..defda98 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -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 }, diff --git a/internal/build/new.go b/internal/build/new.go index 2f32ed8..f48d200 100644 --- a/internal/build/new.go +++ b/internal/build/new.go @@ -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 `. @@ -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. diff --git a/internal/build/new_test.go b/internal/build/new_test.go index 932f1bc..afb79c4 100644 --- a/internal/build/new_test.go +++ b/internal/build/new_test.go @@ -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()) } diff --git a/internal/build/stubs/new.tmpl b/internal/build/stubs/new.tmpl index b75e4be..e825005 100644 --- a/internal/build/stubs/new.tmpl +++ b/internal/build/stubs/new.tmpl @@ -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. # scaffold a plugin module under plugins/ +summer plugin:add plugins/ # 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--app`: an application, like this repository. +- `sm--plugin`: a standalone plugin module. +- `sm--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. +and summer plugin:add plugins/, then run summer build again. +{{else}} +Then, from another terminal: + curl http://127.0.0.1:8080/ +{{end}}{{end}}