feat(build): make summer new apps admin-ready
- 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
This commit is contained in:
@@ -32,6 +32,8 @@ login:
|
|||||||
decay_minutes: 1
|
decay_minutes: 1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
An application created with `summer new` already has a random `SUMMER_ADMIN__JWT__SECRET` in `.env`.
|
||||||
|
|
||||||
The cookie carries the Secure attribute. `backend.cookie_secure: false` drops it for plain-HTTP development and is refused in the `production` environment.
|
The cookie carries the Secure attribute. `backend.cookie_secure: false` drops it for plain-HTTP development and is refused in the `production` environment.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ The application gets these files:
|
|||||||
| `go.work` | The workspace with the application and its plugin modules, as relative paths. It is committed. |
|
| `go.work` | The workspace with the application and its plugin modules, as relative paths. It is committed. |
|
||||||
| `summer.yaml` | The manifest: module path, binary name and plugin list. |
|
| `summer.yaml` | The manifest: module path, binary name and plugin list. |
|
||||||
| `config/` | `app.yaml`, `database.yaml`, `http.yaml` and `storage.yaml`, enough for `serve` and `route:list`. |
|
| `config/` | `app.yaml`, `database.yaml`, `http.yaml` and `storage.yaml`, enough for `serve` and `route:list`. |
|
||||||
| `.env`, `.env.example` | The database DSN and a freshly generated application key. `.env` is readable by its owner only and never committed; `.env.example` lists the variables without the key. |
|
| `.env`, `.env.example` | The database DSN, a freshly generated application key and a random admin token secret (`SUMMER_ADMIN__JWT__SECRET`). `.env` is readable by its owner only and never committed; `.env.example` lists the variables without the key and the secret. |
|
||||||
| `compose.yaml` | PostgreSQL 16 on `127.0.0.1:54320`, with credentials matching `.env`. |
|
| `compose.yaml` | PostgreSQL 16 on `127.0.0.1:54320`, with credentials matching `.env`. |
|
||||||
| `.gitignore` | `bin/`, `tmp/`, `storage/`, `.env` and `go.work.sum`. |
|
| `.gitignore` | `bin/`, `tmp/`, `storage/`, `.env` and `go.work.sum`. |
|
||||||
| `CLAUDE.md`, `AGENTS.md` | Instructions for AI coding agents: the commands, the layout, the links to the documentation for agents and the naming convention. `AGENTS.md` is a symlink to `CLAUDE.md`. |
|
| `CLAUDE.md`, `AGENTS.md` | Instructions for AI coding agents: the commands, the layout, the admin, the links to the documentation for agents and the naming convention. `AGENTS.md` is a symlink to `CLAUDE.md`. |
|
||||||
| `plugins/<name>` | The starter plugin `golem15.<name>`, created by the same code as `summer make:plugin` and registered by the same code as `summer plugin:add`. Its `routes.go` answers `GET /` with a JSON greeting. |
|
| `plugins/<name>` | The starter plugin `golem15.<name>`, created by the same code as `summer make:plugin` and registered by the same code as `summer plugin:add`. Its `routes.go` answers `GET /` with a JSON greeting. |
|
||||||
| `main.go`, `plugins.gen.go` | Generated from `summer.yaml`, as `summer build` does. |
|
| `main.go`, `plugins.gen.go` | Generated from `summer.yaml`, as `summer build` does. |
|
||||||
|
|
||||||
@@ -62,6 +62,17 @@ summer migrate
|
|||||||
summer serve
|
summer serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The next steps also explain the admin. It opens at `/backend` once a plugin registers an admin controller; until then `summer serve` prints `admin: off` with these steps:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
summer make:admin-controller golem15.acme Posts
|
||||||
|
summer build
|
||||||
|
summer migrate
|
||||||
|
summer admin:create --email you@example.com --superuser
|
||||||
|
```
|
||||||
|
|
||||||
|
[Admin controllers](../backend/admin-controllers.md) explains the permission and model the generated controller needs.
|
||||||
|
|
||||||
Golem15 names repositories by what they hold: `sm-<name>-app` for an application such as the one `summer new` writes, `sm-<name>-plugin` for a standalone plugin module and `sm-<name>-theme` for a Nuxt or Vue frontend.
|
Golem15 names repositories by what they hold: `sm-<name>-app` for an application such as the one `summer new` writes, `sm-<name>-plugin` for a standalone plugin module and `sm-<name>-theme` for a Nuxt or Vue frontend.
|
||||||
|
|
||||||
## Creating a plugin
|
## Creating a plugin
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Set them on the `bash` side of the pipe, for example `curl -fsSL https://summerc
|
|||||||
The generated application brings its own database and configuration:
|
The generated application brings its own database and configuration:
|
||||||
|
|
||||||
- `compose.yaml` runs PostgreSQL 16, published on `127.0.0.1:54320` only, so it does not clash with a PostgreSQL already on port 5432.
|
- `compose.yaml` runs PostgreSQL 16, published on `127.0.0.1:54320` only, so it does not clash with a PostgreSQL already on port 5432.
|
||||||
- `.env` holds the matching `SUMMER_DATABASE__DSN` and a freshly generated `SUMMER_APP__KEY`. It is excluded by `.gitignore`; `.env.example` lists the same variables.
|
- `.env` holds the matching `SUMMER_DATABASE__DSN`, a freshly generated `SUMMER_APP__KEY` and a random `SUMMER_ADMIN__JWT__SECRET` for the admin. It is excluded by `.gitignore`; `.env.example` lists the same variables.
|
||||||
- `config/` holds `app.yaml`, `database.yaml`, `http.yaml` and `storage.yaml`, enough for `summer serve` and `route:list`.
|
- `config/` holds `app.yaml`, `database.yaml`, `http.yaml` and `storage.yaml`, enough for `summer serve` and `route:list`.
|
||||||
|
|
||||||
From `sm-demo-app`, start the database, build the binary, run the migrations and serve:
|
From `sm-demo-app`, start the database, build the binary, run the migrations and serve:
|
||||||
@@ -68,6 +68,14 @@ curl http://127.0.0.1:8080/
|
|||||||
|
|
||||||
It returns `{"message":"Hello from SummerCMS","plugin":"golem15.demo"}`. Stop the database with `docker compose down`, or `docker compose down -v` to delete its data as well.
|
It returns `{"message":"Hello from SummerCMS","plugin":"golem15.demo"}`. Stop the database with `docker compose down`, or `docker compose down -v` to delete its data as well.
|
||||||
|
|
||||||
|
`summer serve` prints the URL to open and the admin status. The admin lives at `http://127.0.0.1:8080/backend` once a plugin registers an admin controller; until then the status line reads `admin: off` and names the steps. When the admin is there, create the first administrator:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
summer admin:create --email you@example.com --superuser
|
||||||
|
```
|
||||||
|
|
||||||
|
[Admin controllers](../backend/admin-controllers.md) shows how to add a controller, and [Users and permissions](../backend/users-and-permissions.md) how administrators sign in.
|
||||||
|
|
||||||
## Create an application without the script
|
## Create an application without the script
|
||||||
|
|
||||||
When you already have a framework checkout, or want to read the code before running anything, do the script's steps yourself. Clone the framework into `summercms`, install the tool from it and run `summer new` in the directory next to the checkout:
|
When you already have a framework checkout, or want to read the code before running anything, do the script's steps yourself. Clone the framework into `summercms`, install the tool from it and run `summer new` in the directory next to the checkout:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package build
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -38,6 +39,10 @@ type NewOptions struct {
|
|||||||
// AppKey is written to .env as SUMMER_APP__KEY. It must be the base64
|
// AppKey is written to .env as SUMMER_APP__KEY. It must be the base64
|
||||||
// (standard encoding) of 32 bytes; the CLI passes lagoon.GenerateAppKey.
|
// (standard encoding) of 32 bytes; the CLI passes lagoon.GenerateAppKey.
|
||||||
AppKey string
|
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 skips git init and the first commit.
|
||||||
NoGit bool
|
NoGit bool
|
||||||
}
|
}
|
||||||
@@ -85,6 +90,7 @@ type newData struct {
|
|||||||
Replace string
|
Replace string
|
||||||
DSN string
|
DSN string
|
||||||
AppKey string
|
AppKey string
|
||||||
|
AdminSecret string
|
||||||
NoPlugin bool
|
NoPlugin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +133,16 @@ func NewApp(ctx context.Context, opts NewOptions, out io.Writer) (string, error)
|
|||||||
if err := validateAppKey(opts.AppKey); err != nil {
|
if err := validateAppKey(opts.AppKey); err != nil {
|
||||||
return "", err
|
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
|
parent := opts.Parent
|
||||||
if parent == "" {
|
if parent == "" {
|
||||||
parent = "."
|
parent = "."
|
||||||
@@ -191,6 +207,29 @@ func validateAppKey(key string) error {
|
|||||||
return nil
|
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.
|
// devDSN is the .env database DSN matching compose.yaml.
|
||||||
func devDSN(name string) string {
|
func devDSN(name string) string {
|
||||||
return "postgres://" + devDBUser + ":" + devDBPassword + "@" + devDBHostPort + "/" + name + "?sslmode=disable"
|
return "postgres://" + devDBUser + ":" + devDBPassword + "@" + devDBHostPort + "/" + name + "?sslmode=disable"
|
||||||
@@ -318,6 +357,9 @@ func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) {
|
|||||||
if err := validateAppKey(opts.AppKey); err != nil {
|
if err := validateAppKey(opts.AppKey); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := validateAdminSecret(opts.AdminSecret); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := checkFramework(framework); err != nil {
|
if err := checkFramework(framework); err != nil {
|
||||||
return nil, fmt.Errorf("build: %w", err)
|
return nil, fmt.Errorf("build: %w", err)
|
||||||
}
|
}
|
||||||
@@ -334,6 +376,7 @@ func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) {
|
|||||||
Replace: replace,
|
Replace: replace,
|
||||||
DSN: devDSN(opts.Name),
|
DSN: devDSN(opts.Name),
|
||||||
AppKey: opts.AppKey,
|
AppKey: opts.AppKey,
|
||||||
|
AdminSecret: opts.AdminSecret,
|
||||||
NoPlugin: opts.NoPlugin,
|
NoPlugin: opts.NoPlugin,
|
||||||
}
|
}
|
||||||
plan := []struct {
|
plan := []struct {
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ var goldenAppKey = func() string {
|
|||||||
return base64.StdEncoding.EncodeToString(raw)
|
return base64.StdEncoding.EncodeToString(raw)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// goldenAdminSecret is the base64 of the bytes 0x20..0x3f: a fixed,
|
||||||
|
// non-secret admin token secret that keeps .env byte-stable in the goldens.
|
||||||
|
var goldenAdminSecret = func() string {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
for i := range raw {
|
||||||
|
raw[i] = byte(0x20 + i)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(raw)
|
||||||
|
}()
|
||||||
|
|
||||||
// newGoldenApp runs NewApp for "demo" in a temp parent whose sibling
|
// newGoldenApp runs NewApp for "demo" in a temp parent whose sibling
|
||||||
// summercms is a symlink to this repository, so auto-detection yields the
|
// summercms is a symlink to this repository, so auto-detection yields the
|
||||||
// relative replace ../summercms. It returns the app dir and the output with
|
// relative replace ../summercms. It returns the app dir and the output with
|
||||||
@@ -41,6 +51,7 @@ func newGoldenApp(t *testing.T, noPlugin bool) (string, []byte) {
|
|||||||
Name: "demo",
|
Name: "demo",
|
||||||
Parent: parent,
|
Parent: parent,
|
||||||
AppKey: goldenAppKey,
|
AppKey: goldenAppKey,
|
||||||
|
AdminSecret: goldenAdminSecret,
|
||||||
NoGit: true,
|
NoGit: true,
|
||||||
NoPlugin: noPlugin,
|
NoPlugin: noPlugin,
|
||||||
}, &out)
|
}, &out)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package build
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"go/token"
|
"go/token"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
@@ -642,3 +643,88 @@ func TestNewNoGit(t *testing.T) {
|
|||||||
t.Fatalf("output lacks the created line:\n%s", out.String())
|
t.Fatalf("output lacks the created line:\n%s", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envValue returns the value of key in an env file body, or "" and false.
|
||||||
|
func envValue(body []byte, key string) (string, bool) {
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, key+"="); ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewAdminSecret writes SUMMER_ADMIN__JWT__SECRET to .env only, generates
|
||||||
|
// a random one when none is given and rejects values unsafe for .env.
|
||||||
|
func TestNewAdminSecret(t *testing.T) {
|
||||||
|
const key = "SUMMER_ADMIN__JWT__SECRET"
|
||||||
|
|
||||||
|
t.Run("explicit", func(t *testing.T) {
|
||||||
|
const secret = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY="
|
||||||
|
parent := parentWithFramework(t)
|
||||||
|
var out bytes.Buffer
|
||||||
|
dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, AdminSecret: secret, NoGit: true, NoPlugin: true}, &out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env := readAppFile(t, dir, ".env")
|
||||||
|
if !strings.Contains(string(env), "\n"+key+"="+secret+"\n") {
|
||||||
|
t.Fatalf(".env does not hold %s=%s:\n%s", key, secret, env)
|
||||||
|
}
|
||||||
|
st, err := os.Stat(filepath.Join(dir, ".env"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf(".env mode = %o, want 600", st.Mode().Perm())
|
||||||
|
}
|
||||||
|
example := readAppFile(t, dir, ".env.example")
|
||||||
|
if v, ok := envValue(example, key); !ok || v != "" {
|
||||||
|
t.Fatalf(".env.example %s = (%q, %v), want an empty placeholder", key, v, ok)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(example), secret) {
|
||||||
|
t.Fatal(".env.example contains the admin secret")
|
||||||
|
}
|
||||||
|
if strings.Contains(out.String(), secret) {
|
||||||
|
t.Fatal("the next steps print the admin secret")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("generated", func(t *testing.T) {
|
||||||
|
var seen []string
|
||||||
|
for range 2 {
|
||||||
|
parent := parentWithFramework(t)
|
||||||
|
dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true, NoPlugin: true}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v, ok := envValue(readAppFile(t, dir, ".env"), key)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf(".env has no %s", key)
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(v)
|
||||||
|
if err != nil || len(raw) != 32 {
|
||||||
|
t.Fatalf("generated secret %q is not the base64 of 32 bytes (%v)", v, err)
|
||||||
|
}
|
||||||
|
seen = append(seen, v)
|
||||||
|
}
|
||||||
|
if seen[0] == seen[1] {
|
||||||
|
t.Fatal("two apps got the same admin secret")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid", func(t *testing.T) {
|
||||||
|
for _, secret := range []string{
|
||||||
|
"short",
|
||||||
|
"abcdefghijklmnopqrst uvwxyzABCDEFGHIJKLMN",
|
||||||
|
"abcdefghijklmnopqrst\nuvwxyzABCDEFGHIJKLMN",
|
||||||
|
} {
|
||||||
|
parent := parentWithFramework(t)
|
||||||
|
_, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, AdminSecret: secret, NoGit: true, NoPlugin: true}, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "admin secret") {
|
||||||
|
t.Fatalf("NewApp(AdminSecret %q) = %v, want an admin secret error", secret, err)
|
||||||
|
}
|
||||||
|
assertDirEntries(t, parent, []string{"summercms"})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,11 +34,12 @@ uploads:
|
|||||||
public_path_prefix: "/storage/uploads"
|
public_path_prefix: "/storage/uploads"
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "new/env"}}# Local secrets for this application: the database DSN and the application
|
{{define "new/env"}}# Local secrets for this application: the database DSN, the application key
|
||||||
# key. .gitignore excludes this file; never commit it. Real environment
|
# and the admin token secret. .gitignore excludes this file; never commit it.
|
||||||
# variables override the values here.
|
# Real environment variables override the values here.
|
||||||
SUMMER_DATABASE__DSN={{.DSN}}
|
SUMMER_DATABASE__DSN={{.DSN}}
|
||||||
SUMMER_APP__KEY={{.AppKey}}
|
SUMMER_APP__KEY={{.AppKey}}
|
||||||
|
SUMMER_ADMIN__JWT__SECRET={{.AdminSecret}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "new/env.example"}}# The variables .env sets. summer new wrote a .env with a fresh key; copy this
|
{{define "new/env.example"}}# The variables .env sets. summer new wrote a .env with a fresh key; copy this
|
||||||
@@ -47,6 +48,10 @@ SUMMER_DATABASE__DSN={{.DSN}}
|
|||||||
# A 32-byte base64 application key. Generate one with ./bin/{{.Name}} key:generate
|
# A 32-byte base64 application key. Generate one with ./bin/{{.Name}} key:generate
|
||||||
# and paste only the key, without the leading check mark.
|
# and paste only the key, without the leading check mark.
|
||||||
SUMMER_APP__KEY=
|
SUMMER_APP__KEY=
|
||||||
|
# Signs backend admin tokens; the admin refuses to start without it once a
|
||||||
|
# plugin registers an admin controller. summer new wrote a random value to .env;
|
||||||
|
# any long random string works, for example one from ./bin/{{.Name}} key:generate.
|
||||||
|
SUMMER_ADMIN__JWT__SECRET=
|
||||||
# Uploads bucket; config/storage.yaml already sets this default.
|
# Uploads bucket; config/storage.yaml already sets this default.
|
||||||
# SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://./storage/app/uploads
|
# SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://./storage/app/uploads
|
||||||
# Configuration environment; selects the config overlay (default production).
|
# Configuration environment; selects the config overlay (default production).
|
||||||
@@ -110,6 +115,8 @@ summer migrate # run plugin migrations
|
|||||||
summer serve # start the HTTP server
|
summer serve # start the HTTP server
|
||||||
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/<name>
|
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
|
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
|
||||||
|
summer make:admin-controller golem15.<plugin> <Name> # scaffold an admin controller with its YAML
|
||||||
|
summer admin:create --email you@example.com --superuser # create a backend administrator (password prompted)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
@@ -117,13 +124,23 @@ summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.w
|
|||||||
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
|
- `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}}
|
- `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`).
|
- `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.
|
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
|
||||||
- `compose.yaml`: the local PostgreSQL 16.
|
- `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.
|
- `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.
|
- `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`.
|
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`.
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
|
||||||
|
The backend admin (the admin SPA and its JSON API) lives under `/backend`, at `http://127.0.0.1:8080/backend` with the default `summer serve` address. It is mounted only when a plugin registers an admin controller; until then `summer serve` prints `admin: off`. To add it:
|
||||||
|
|
||||||
|
1. `summer make:admin-controller golem15.<plugin> <Name>` scaffolds the controller and its YAML. The generated controller requires the permission `golem15.<plugin>.access_<name>` (`<name>` is the controller name in snake case): declare it in the plugin's `Permissions()` (`pact.HasPermissions`) or the admin refuses to start. Its `NewRecord` returns `nil` until you return the plugin's model.
|
||||||
|
2. `summer build`, then `summer migrate`.
|
||||||
|
3. `summer admin:create --email you@example.com --superuser` creates the first administrator; the password is prompted.
|
||||||
|
|
||||||
|
`.env` already holds `SUMMER_ADMIN__JWT__SECRET`, which signs admin tokens.
|
||||||
|
|
||||||
## Naming convention
|
## Naming convention
|
||||||
|
|
||||||
- `sm-<name>-app`: an application, like this repository.
|
- `sm-<name>-app`: an application, like this repository.
|
||||||
@@ -143,4 +160,11 @@ and summer plugin:add plugins/<name>, then run summer build again.
|
|||||||
{{else}}
|
{{else}}
|
||||||
Then, from another terminal:
|
Then, from another terminal:
|
||||||
curl http://127.0.0.1:8080/
|
curl http://127.0.0.1:8080/
|
||||||
{{end}}{{end}}
|
{{end}}
|
||||||
|
Admin, at http://127.0.0.1:8080/backend once a plugin registers an admin
|
||||||
|
controller (.env already holds SUMMER_ADMIN__JWT__SECRET):
|
||||||
|
summer make:admin-controller golem15.<plugin> <Name>
|
||||||
|
summer build
|
||||||
|
summer migrate
|
||||||
|
summer admin:create --email you@example.com --superuser
|
||||||
|
{{end}}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ summer migrate # run plugin migrations
|
|||||||
summer serve # start the HTTP server
|
summer serve # start the HTTP server
|
||||||
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/<name>
|
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
|
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
|
||||||
|
summer make:admin-controller golem15.<plugin> <Name> # scaffold an admin controller with its YAML
|
||||||
|
summer admin:create --email you@example.com --superuser # create a backend administrator (password prompted)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
@@ -30,13 +32,23 @@ summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.w
|
|||||||
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
|
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
|
||||||
- `plugins/`: local plugin modules.
|
- `plugins/`: local plugin modules.
|
||||||
- `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`).
|
- `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.
|
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
|
||||||
- `compose.yaml`: the local PostgreSQL 16.
|
- `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.
|
- `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.
|
- `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`.
|
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`.
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
|
||||||
|
The backend admin (the admin SPA and its JSON API) lives under `/backend`, at `http://127.0.0.1:8080/backend` with the default `summer serve` address. It is mounted only when a plugin registers an admin controller; until then `summer serve` prints `admin: off`. To add it:
|
||||||
|
|
||||||
|
1. `summer make:admin-controller golem15.<plugin> <Name>` scaffolds the controller and its YAML. The generated controller requires the permission `golem15.<plugin>.access_<name>` (`<name>` is the controller name in snake case): declare it in the plugin's `Permissions()` (`pact.HasPermissions`) or the admin refuses to start. Its `NewRecord` returns `nil` until you return the plugin's model.
|
||||||
|
2. `summer build`, then `summer migrate`.
|
||||||
|
3. `summer admin:create --email you@example.com --superuser` creates the first administrator; the password is prompted.
|
||||||
|
|
||||||
|
`.env` already holds `SUMMER_ADMIN__JWT__SECRET`, which signs admin tokens.
|
||||||
|
|
||||||
## Naming convention
|
## Naming convention
|
||||||
|
|
||||||
- `sm-<name>-app`: an application, like this repository.
|
- `sm-<name>-app`: an application, like this repository.
|
||||||
|
|||||||
@@ -8,3 +8,10 @@ Next steps, inside sm-demo-app:
|
|||||||
|
|
||||||
The app has no plugins yet. Add one with summer make:plugin golem15.<name>
|
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.
|
and summer plugin:add plugins/<name>, then run summer build again.
|
||||||
|
|
||||||
|
Admin, at http://127.0.0.1:8080/backend once a plugin registers an admin
|
||||||
|
controller (.env already holds SUMMER_ADMIN__JWT__SECRET):
|
||||||
|
summer make:admin-controller golem15.<plugin> <Name>
|
||||||
|
summer build
|
||||||
|
summer migrate
|
||||||
|
summer admin:create --email you@example.com --superuser
|
||||||
|
|||||||
14
internal/build/testdata/new/CLAUDE.md.golden
vendored
14
internal/build/testdata/new/CLAUDE.md.golden
vendored
@@ -23,6 +23,8 @@ summer migrate # run plugin migrations
|
|||||||
summer serve # start the HTTP server
|
summer serve # start the HTTP server
|
||||||
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/<name>
|
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
|
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
|
||||||
|
summer make:admin-controller golem15.<plugin> <Name> # scaffold an admin controller with its YAML
|
||||||
|
summer admin:create --email you@example.com --superuser # create a backend administrator (password prompted)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
@@ -30,13 +32,23 @@ summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.w
|
|||||||
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
|
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
|
||||||
- `plugins/`: local plugin modules. `plugins/demo` is the starter plugin `golem15.demo`; its `routes.go` answers `GET /` with JSON.
|
- `plugins/`: local plugin modules. `plugins/demo` is the starter plugin `golem15.demo`; its `routes.go` answers `GET /` with JSON.
|
||||||
- `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`).
|
- `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.
|
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
|
||||||
- `compose.yaml`: the local PostgreSQL 16.
|
- `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.
|
- `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.
|
- `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`.
|
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`.
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
|
||||||
|
The backend admin (the admin SPA and its JSON API) lives under `/backend`, at `http://127.0.0.1:8080/backend` with the default `summer serve` address. It is mounted only when a plugin registers an admin controller; until then `summer serve` prints `admin: off`. To add it:
|
||||||
|
|
||||||
|
1. `summer make:admin-controller golem15.<plugin> <Name>` scaffolds the controller and its YAML. The generated controller requires the permission `golem15.<plugin>.access_<name>` (`<name>` is the controller name in snake case): declare it in the plugin's `Permissions()` (`pact.HasPermissions`) or the admin refuses to start. Its `NewRecord` returns `nil` until you return the plugin's model.
|
||||||
|
2. `summer build`, then `summer migrate`.
|
||||||
|
3. `summer admin:create --email you@example.com --superuser` creates the first administrator; the password is prompted.
|
||||||
|
|
||||||
|
`.env` already holds `SUMMER_ADMIN__JWT__SECRET`, which signs admin tokens.
|
||||||
|
|
||||||
## Naming convention
|
## Naming convention
|
||||||
|
|
||||||
- `sm-<name>-app`: an application, like this repository.
|
- `sm-<name>-app`: an application, like this repository.
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ SUMMER_DATABASE__DSN=postgres://summer:summer@127.0.0.1:54320/demo?sslmode=disab
|
|||||||
# A 32-byte base64 application key. Generate one with ./bin/demo key:generate
|
# A 32-byte base64 application key. Generate one with ./bin/demo key:generate
|
||||||
# and paste only the key, without the leading check mark.
|
# and paste only the key, without the leading check mark.
|
||||||
SUMMER_APP__KEY=
|
SUMMER_APP__KEY=
|
||||||
|
# Signs backend admin tokens; the admin refuses to start without it once a
|
||||||
|
# plugin registers an admin controller. summer new wrote a random value to .env;
|
||||||
|
# any long random string works, for example one from ./bin/demo key:generate.
|
||||||
|
SUMMER_ADMIN__JWT__SECRET=
|
||||||
# Uploads bucket; config/storage.yaml already sets this default.
|
# Uploads bucket; config/storage.yaml already sets this default.
|
||||||
# SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://./storage/app/uploads
|
# SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://./storage/app/uploads
|
||||||
# Configuration environment; selects the config overlay (default production).
|
# Configuration environment; selects the config overlay (default production).
|
||||||
|
|||||||
7
internal/build/testdata/new/dot-env.golden
vendored
7
internal/build/testdata/new/dot-env.golden
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Local secrets for this application: the database DSN and the application
|
# Local secrets for this application: the database DSN, the application key
|
||||||
# key. .gitignore excludes this file; never commit it. Real environment
|
# and the admin token secret. .gitignore excludes this file; never commit it.
|
||||||
# variables override the values here.
|
# Real environment variables override the values here.
|
||||||
SUMMER_DATABASE__DSN=postgres://summer:summer@127.0.0.1:54320/demo?sslmode=disable
|
SUMMER_DATABASE__DSN=postgres://summer:summer@127.0.0.1:54320/demo?sslmode=disable
|
||||||
SUMMER_APP__KEY=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
SUMMER_APP__KEY=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
||||||
|
SUMMER_ADMIN__JWT__SECRET=ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=
|
||||||
|
|||||||
@@ -8,3 +8,10 @@ Next steps, inside sm-demo-app:
|
|||||||
|
|
||||||
Then, from another terminal:
|
Then, from another terminal:
|
||||||
curl http://127.0.0.1:8080/
|
curl http://127.0.0.1:8080/
|
||||||
|
|
||||||
|
Admin, at http://127.0.0.1:8080/backend once a plugin registers an admin
|
||||||
|
controller (.env already holds SUMMER_ADMIN__JWT__SECRET):
|
||||||
|
summer make:admin-controller golem15.<plugin> <Name>
|
||||||
|
summer build
|
||||||
|
summer migrate
|
||||||
|
summer admin:create --email you@example.com --superuser
|
||||||
|
|||||||
Reference in New Issue
Block a user