199 lines
16 KiB
Markdown
199 lines
16 KiB
Markdown
# Phase 4: CLI scaffolding, i18n and mail — Pattern Map
|
||
|
||
**Mapped:** 2026-09-18
|
||
**Scope:** framework repository only; paths with `<plugin>` describe generated output, not edits to `fonoteka.go`.
|
||
**Files classified:** 29 file or file-family targets
|
||
**Analogs found:** 24/29 (including partial matches)
|
||
|
||
## File Classification
|
||
|
||
| New/Modified File or Family | Role | Data Flow | Closest Existing Analog | Match |
|
||
|---|---|---|---|---|
|
||
| `cmd/summer/main.go` | CLI controller | request-response | `cmd/summer/main.go:26-82` | exact |
|
||
| `internal/build/scaffold.go` | service | file I/O | `internal/build/scaffold.go:24-81,219-250` | exact |
|
||
| `internal/build/registry.go` (or equivalent) | utility | transform/file I/O | `internal/build/build.go:126-165` | role |
|
||
| `internal/build/leaf.go` (or equivalent) | utility | file I/O | `internal/build/scaffold.go:219-250` | partial |
|
||
| `internal/build/stubs/*.tmpl` | template/config | transform | no embedded Go templates exist | none |
|
||
| `internal/build/build.go` | service | batch/file I/O | `internal/build/build.go:18-75` | exact |
|
||
| `pact/capabilities.go` | contracts | request-response | `pact/capabilities.go:11-53` | exact |
|
||
| `phrasebook/loader.go` | service | file I/O/transform | `compass/config.go:288-332` | role |
|
||
| `phrasebook/translator.go` | service | request-response | `compass/config.go:112-164` | partial |
|
||
| `phrasebook/plural.go` | utility | transform | no CLDR or pipe-form parser exists | none |
|
||
| `postcard/mailer.go` | service | request-response | `lagoon/connection.go:82-111` | partial |
|
||
| `postcard/templates.go` | service | file I/O/transform | `compass/config.go:288-332` | partial |
|
||
| `postcard/drivers.go` and `postcard/log.go` | service | request-response | `lagoon/connection.go:26-48` | partial |
|
||
| `postcard/smtp.go` | service | request-response | no SMTP sender exists | none |
|
||
| `postcard/memory.go` | service | event-driven | `backpack/services.go:9-60` | partial |
|
||
| `examples/hello/plugins/base/plugin.go` | provider | file I/O | same file, lines 3-30 | exact |
|
||
| `examples/hello/plugins/base/lang/{en,pl}/*.yaml` | asset/config | file I/O | `examples/hello/plugins/base/config/config.yaml` | partial |
|
||
| `examples/hello/plugins/base/views/mail/*.htm` | asset/config | file I/O/transform | no mail asset exists | none |
|
||
| `examples/hello/config/app.yaml`, `config/mail.yaml` | config | file I/O | `examples/hello/config/app.yaml:1-2` | role |
|
||
| `go.mod`, `go.sum` | config | dependency resolution | `go.mod:6-23` | exact |
|
||
| `<plugin>/plugin.go` and `<plugin>/registry.gen.go` | provider/generated config | batch | `examples/hello/plugins/base/plugin.go:17-30`; `internal/build/build.go:126-143` | role |
|
||
| `<plugin>/models/<name>.go` | model | CRUD | `pact/capabilities.go:51-54` only | partial |
|
||
| `<plugin>/updates/<migration>.go`, `updates/migrations.go` | migration | CRUD | `lagoon/migrations_test.go:147-181` | role |
|
||
| `<plugin>/console/<name>.go` | CLI controller | request-response | `examples/hello/plugins/greeter/plugin.go:78-119` | role |
|
||
| `<plugin>/jobs/<name>.go`, `<plugin>/controllers/<name>.go` and YAML | job/controller | event-driven/request-response | no job or admin controller implementation exists | none |
|
||
| `internal/build/build_test.go`, `cmd/summer/main_test.go` | test | batch | same files, `build_test.go:143-242,340-485`; `main_test.go:17-40` | exact |
|
||
| `phrasebook/*_test.go`, `postcard/*_test.go` | test | request-response | `pact/capabilities_test.go:87-100`; `backpack/services_test.go` | role |
|
||
| `postcard/mailpit_test.go` | integration test | request-response | `lagoon/postgres_test.go:25-103` | role |
|
||
| `examples/hello/hello_test.go` | integration test | request-response | same file, lines 59-89, 91-108 | exact |
|
||
|
||
The file names within new packages are planning suggestions; D-01–D-21 fix behavior and generated directory shapes, not implementation file splits. `go.sum` changes when dependencies are added. Generated assets under `<plugin>` are products of `make:*`, with a temporary `examples/hello` copy as the compile/vet fixture.
|
||
|
||
## Pattern Assignments
|
||
|
||
### CLI command registration — `cmd/summer/main.go`
|
||
|
||
Copy `cmd/summer/main.go:26-40` to register all five new commands; copy `:56-82` for argument lookup, working-directory resolution, delegation to `internal/build`, and output. A typical existing command is:
|
||
|
||
```go
|
||
func makePluginCommand() bonfire.Command {
|
||
return bonfire.Command{
|
||
Name: "make:plugin",
|
||
Args: []bonfire.Arg{{Name: "id", Required: true}},
|
||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||
id, ok := in.Argument("id")
|
||
if !ok || id == "" { return fmt.Errorf("make:plugin requires a vendor.plugin id") }
|
||
dir, err := os.Getwd()
|
||
if err != nil { return err }
|
||
created, err := build.MakePlugin(ctx, dir, id)
|
||
if err != nil { return err }
|
||
out.Printf("created %s\n", created)
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
```
|
||
|
||
For `make:<kind>`, expose both `<vendor.plugin> <Name>` and the in-plugin omitted-ID form from D-17. `bonfire.Arg`/`Flag` definitions are the CLI validation surface; `build.ValidatePluginID` in `internal/build/manifest.go:110-122` supplies plugin ID validation. Add every new command name to `cmd/summer/main_test.go:17-40`.
|
||
|
||
### Scaffold, registry and leaf check — `internal/build/`
|
||
|
||
`internal/build/scaffold.go:24-81` is the existing create flow: validate ID, locate app root, check `underRoot`, reject an existing target, derive module path, write files, run `go mod tidy`, and preserve the pinned toolchain. `:219-250` parses `plugin.go` with `go/parser` and extracts the literal ID; use this when `<vendor.plugin>` is omitted. `:417-425` is the path containment check. `internal/build/manifest.go:167-189` rejects dangerous module paths.
|
||
|
||
For `registry.gen.go`, copy deterministic generation and idempotent output from `internal/build/build.go:126-165`:
|
||
|
||
```go
|
||
func formatSource(src []byte) ([]byte, error) {
|
||
formatted, err := format.Source(src)
|
||
if err != nil { return nil, fmt.Errorf("build: format generated go: %w", err) }
|
||
return formatted, nil
|
||
}
|
||
|
||
func writeIfChanged(path string, content []byte) error {
|
||
existing, err := os.ReadFile(path)
|
||
if err == nil && bytes.Equal(existing, content) { return nil }
|
||
if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("build: read %s: %w", path, err) }
|
||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||
return fmt.Errorf("build: write %s: %w", path, err)
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
The source builder in `scaffold.go:159-180` currently uses `strings.Builder` and `format.Source`; D-16 replaces the string builder with `go:embed` + `text/template` under `internal/build/stubs/*.tmpl`. There is no in-repo embedded-template analog. Preserve its error wrapping, formatting, `party.Register(&Plugin{})`, and `go.mod` generation (`:182-216`). Use parsed imports as in `scaffold.go:219-250` and `cmd/summer/main_test.go:42-69` for the `models/` sibling-import check. Run that check from `build.App` before the `go build` subprocess at `internal/build/build.go:33-56`, reporting plugin ID, source path, and import path.
|
||
|
||
**Generated registry contract:** Root `plugin.go` may import `models/`, `console/`, `jobs/`, `controllers/`, `updates/`; `models/` imports no sibling. Generated `registry.gen.go` owns only sorted/generated entries. Hand-written `plugin.go` must never be rewritten when a later `make:*` runs. If a hand-written plugin lacks accessors, create the registry and print the one-time accessor instruction (D-12). The existing `writeIfChanged` avoids rewriting identical bytes; use a temporary-file rename for registry updates as research recommends.
|
||
|
||
### Generated plugin artifacts
|
||
|
||
Use `examples/hello/plugins/base/plugin.go:3-30` as the root-plugin pattern:
|
||
|
||
```go
|
||
//go:embed config
|
||
var configFS embed.FS
|
||
|
||
type Plugin struct{}
|
||
func (p *Plugin) ID() string { return "golem15.hello" }
|
||
func (p *Plugin) Register(*backpack.App) error { return nil }
|
||
func (p *Plugin) Boot(*backpack.App) error { return nil }
|
||
func (p *Plugin) ConfigFS() fs.FS { return configFS }
|
||
func init() { party.Register(&Plugin{}) }
|
||
```
|
||
|
||
Add `HasLang`/`HasMailTemplates` embedded FS accessors only when matching asset files exist; empty directories do not satisfy `go:embed`. For generated commands, copy the `bonfire.Command` returned by `examples/hello/plugins/greeter/plugin.go:78-119`, particularly `Run(ctx, in, out)` and colon command names. For migrations, the closest concrete gormigrate set is `lagoon/migrations_test.go:147-181`:
|
||
|
||
```go
|
||
&gormigrate.Migration{
|
||
ID: "202609170001_create_alpha",
|
||
Migrate: func(tx *gorm.DB) error {
|
||
return tx.Exec(`CREATE TABLE lagoon_alpha (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL)`).Error
|
||
},
|
||
Rollback: func(tx *gorm.DB) error {
|
||
return tx.Exec(`DROP TABLE IF EXISTS lagoon_alpha`).Error
|
||
},
|
||
}
|
||
```
|
||
|
||
`pact.HasMigrations` returns an ordered `[]*gormigrate.Migration` (`pact/capabilities.go:23-28`); `lagoon/migrations.go:59-81` consumes it in activation order. The model stub should expose itself through `pact.HasModels` (`pact/capabilities.go:51-54`) and its create-table migration through the generated `updates/` set. `make:model --no-migration` omits only the migration. No production model, job, or admin-controller source exists in this repo, so D-13–D-15 and RESEARCH.md define their initial signatures.
|
||
|
||
### Optional capabilities and boot wiring — `pact`, `party`, `backpack`
|
||
|
||
Define `HasLang` and `HasMailTemplates` beside `pact.HasConfig` in `pact/capabilities.go:16-21`; its shape is `fs.FS` returned by an accessor. `pact/capabilities_test.go:12-16,87-100` shows `fstest.MapFS` fixtures and interface assertions. `party/registry.go:101-124` is the fixed capability/boot point:
|
||
|
||
```go
|
||
for _, p := range ordered {
|
||
if hc, ok := p.(pact.HasConfig); ok && app.Config != nil {
|
||
if err := app.Config.MergePlugin(p.ID(), hc.ConfigFS()); err != nil {
|
||
return nil, fmt.Errorf("party: config %s: %w", p.ID(), err)
|
||
}
|
||
}
|
||
}
|
||
for _, p := range ordered {
|
||
if err := p.Register(app); err != nil { return nil, fmt.Errorf("party: register %s: %w", p.ID(), err) }
|
||
}
|
||
for _, p := range ordered {
|
||
if err := p.Boot(app); err != nil { return nil, fmt.Errorf("party: boot %s: %w", p.ID(), err) }
|
||
}
|
||
```
|
||
|
||
Place phrasebook/postcard asset loading so services are available to plugin `Boot`, while keeping `party` free of an import cycle. Preserve plugin order and name plugin ID plus missing template/layout in returned errors. `examples/hello/plugins/optional/plugin.go:21-30` publishes a service during `Register`; `backpack/app.go:59-74` and `backpack/services.go:20-59` define typed publish/lookup and duplicate-provider errors. `lagoon/connection.go:98-111` is another explicit service publisher. Publish translator and `postcard.Mailer` at app scope, not process scope.
|
||
|
||
### Phrasebook — `phrasebook/`
|
||
|
||
For embedded YAML loading, use `compass/config.go:288-332`: `fs.WalkDir`, sorted file paths, `fs.ReadFile`, contextual parse errors, then map conversion. Phrasebook must add the `lang/<locale>/<group>.yaml` path contract, flatten nested keys, and reject duplicate namespace ownership and malformed leaves. The existing config lookup pattern is `compass/config.go:112-164` (`Lookup`, `String`, `LoadSection`), but translation lookup must implement D-04's requested locale → parent → configured fallback → raw key sequence itself.
|
||
|
||
`towel/context.go:7-23,55-63` **already supplies** an unexported `localeKey`, `WithLocale`, and `Locale`. `surf/router.go:324-327` writes the `Accept-Language` header there. Use that accessor as the Phase 7 context seam; avoid a second context key that cannot read middleware values. There is no CLDR/pipe parser analog; follow D-02/D-03 and RESEARCH.md: go-i18n selects a category label, phrasebook substitutes `:name`, `:Name`, `:NAME` itself, and pipe conditions are parsed explicitly. `go.mod:6-23` already has `github.com/goccy/go-yaml v1.19.2`; go-i18n is a new dependency.
|
||
|
||
### Postcard — `postcard/`
|
||
|
||
Use the `compass/config.go:288-332` FS walk/read/error pattern for template registration, but write a dedicated INI-header/`==` parser: no production template parser exists. The actual mail format is fixed by D-06–D-09, including three layout sections and caller-selected `-en` siblings. `postcard.Mailer.Send(ctx, postcard.Message{...})` is a new service; use `lagoon/connection.go:82-111` for typed `mail.*` config loading and app publication. `compass/config.go:157-164` supports `LoadSection("mail", &opts)`; `compass/env.go:31-44` maps `SUMMER_MAIL__...` to `mail.*` automatically.
|
||
|
||
For driver errors, copy `lagoon/connection.go:26-48`: validate required settings early, wrap the underlying error with package context, clean up partial resources, and return errors to the caller. SMTP and Goldmark have no in-repo analog. D-07 and RESEARCH.md specify the render chain: `html/template` on Markdown source, Goldmark to HTML, substituted Markdown for text, and internally trusted layout HTML only. Test final HTML with HTML tags, Markdown links, and unsafe URLs; reject subject CR/LF before constructing mail headers. Keep explicit SMTP TLS policy and do not put credentials in log/error text.
|
||
|
||
### Test and fixture patterns
|
||
|
||
`internal/build/build_test.go:143-242` copies `examples/hello`, calls `MakePlugin`/`AddPlugin`, builds twice, and checks byte stability. The copy helper is at `:410-477`; it rewrites the framework `replace` before running Go commands. Extend this test to create every artifact type, run `go build` **and** `go vet`, check idempotent registry contents, and assert a `models/` sibling import is rejected. `assertScaffoldFiles` at `:340-408` currently treats any new file beyond `go.mod`, `plugin.go`, and `config/.gitkeep` as an error; update its allowlist for D-10/D-16.
|
||
|
||
Use `pact/capabilities_test.go:87-100` for `fstest.MapFS` translation/template fixtures. Use `examples/hello/hello_test.go:59-89` to prove service availability through `party.Activate`. `lagoon/postgres_test.go:25-39,92-103` is the Docker gate: full test fails when Docker is unavailable, `-short` skips. Its container start/terminate and bounded timeout at `:25-89` are the closest Mailpit pattern; Mailpit adds HTTP API polling after SMTP send. `go.mod:18-19` already includes testcontainers core; Mailpit is a new image/API fixture.
|
||
|
||
## Shared Patterns
|
||
|
||
| Concern | Source | Apply To |
|
||
|---|---|---|
|
||
| Plugin order and named boot errors | `party/registry.go:96-124` | phrasebook/postcard registration and asset validation |
|
||
| Optional FS capability | `pact/capabilities.go:16-28`, `examples/hello/plugins/base/plugin.go:12-26` | `HasLang`, `HasMailTemplates`, generated plugin |
|
||
| App-scoped services | `backpack/app.go:59-74`, `examples/hello/plugins/optional/plugin.go:21-30` | translator and mailer |
|
||
| Layered config/env | `compass/config.go:157-164`, `compass/env.go:31-44` | `app.locale`, `app.fallback_locale`, `mail.*` |
|
||
| Embedded file parsing | `compass/config.go:288-332` | translation YAML and mail assets |
|
||
| Safe generated paths and source | `internal/build/manifest.go:110-122,167-189`, `internal/build/build.go:146-165` | all `make:*` outputs |
|
||
| Locale context | `towel/context.go:55-63`, `surf/router.go:324-327` | phrasebook lookup seam |
|
||
| Integration test Docker gate | `lagoon/postgres_test.go:25-39,92-103` | Mailpit SMTP test |
|
||
|
||
## No Close Analog Found
|
||
|
||
| Target | Why research/decisions must supply the pattern |
|
||
|---|---|
|
||
| `internal/build/stubs/*.tmpl` | Existing generated Go uses string builders, not embedded templates. |
|
||
| `phrasebook/plural.go` | No CLDR category selection or Laravel pipe parser exists. |
|
||
| `postcard/smtp.go` | No SMTP sender exists. |
|
||
| `examples/hello/plugins/base/views/mail/*.htm` | No Winter shaped mail asset exists in this repository; use CONTEXT.md's PHP canonical reference. |
|
||
| Generated `jobs/`, admin `controllers/` and `fields.yaml`/`columns.yaml` | No framework job or admin-controller implementation exists yet; D-14/D-15 define minimum compile contracts. |
|
||
|
||
## Metadata
|
||
|
||
**Search scope:** `cmd/summer`, `internal/build`, `pact`, `party`, `backpack`, `compass`, `lagoon`, `surf`, `towel`, `examples/hello`, `go.mod`; scanned Go/YAML files with `rg --files` and capability/test patterns with `rg -n`.
|
||
**Pattern extraction date:** 2026-09-18.
|
||
**Boundary:** No changes to `../fonoteka.go`; Phase 3 flat plugins are not restructured here.
|