16 KiB
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:
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:
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: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:
&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:
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.