Scaffolding
The engine that turns the vendored app-template into a running satellite — and grows it a capability at a time. One code path serves both callers: the veni CLI and the wizard's dev-gated endpoint. This page is how it works and how to extend it; to use it on a real app, see Build a satellite.
The scaffolder
services/scaffolder.ts is the whole engine. Given a name, ports and a layer set, it:
- Copies
vendor/app-template/into the target dir. - Token-replaces placeholder markers in both file contents and paths — only the exact named markers below match, so a JSX double-brace expression is left untouched.
- Applies the database recipe when
--dbis set (see below).
The markers, from one ordered map:
{{serviceName}} {{apiPort}} {{uiPort}}plus the case variants serviceNameId, serviceName|upper, serviceNamePascal, serviceNameCamel.
The same scaffolder.ts runs whether the CLI invokes it locally (veni create, veni enable) or the wizard does through builder.controller's dev-gated POST /api/builder/scaffold. scaffolder.test.ts (vitest) asserts the two produce byte-identical output, so the CLI path and the server path can never drift:
The template and its recipes
The template is additive-native — it grows, never shrinks:
- Base = SSO-only.
vendor/app-template/api/srcships anauth.service.tsthat verifies Shell tokens but has no database, nouser.service/user.controller. Identity works before any DB. - The database is a recipe, not the default. DB-backed auth + user CRUD live in
api/src/.recipes/database/— a fragment the base scaffold never copies out.applyDatabaseRecipe()grafts it: it copies the fragment (token-replaced) and re-wiresapplication.ts, the barrels and.envin place, guarded so a re-run is idempotent. - One recipe, two entry points.
veni create --dbandveni enable databaseboth callapplyDatabaseRecipe()— the single source of truth the convergence test pins. - Storage is a recipe too.
api/src/.recipes/storage/holdsStorageClientServiceand its test.applyStorageRecipe()(veni enable storage) copies them and wiresapplication.ts, the services barrel,ErrorCode,env.config.ts, the env files, the overlay's env patch and the API deployment's ServiceAccount-token volume. Like the database recipe, it plans every file before writing any, refuses as a whole, and is idempotent. - Email is a recipe too.
api/src/.recipes/email/holdsEmailClientServiceand its test;applyEmailRecipe()(veni enable email) copies them and wiresapplication.ts, the barrel andErrorCode. The Shell identity (APP_ENV_SHELL_*, the overlay entries, the token volume) is one shared step both recipes call, each piece guarded on its own, so storage and email can be enabled in either order and the identity is added once.
Capabilities and the wiring anchors
veni enable is data-driven by api/src/assets/capabilities.json. Each capability declares its prerequisites (requires), so the engine auto-pulls them in topological order — enable authz on a UI-only app quietly lays api then database first:
Each capability also declares how to lay itself down:
"database": {
"title": "Database",
"requires": ["api"], // the DAG — prerequisites auto-pulled in topo order
"materialize": "enable-database", // the handler in enable.ts that lays files + injects wiring
"swaps": "auth.service SSO-only → DB-backed; restores user CRUD; uncomments APP_ENV_DATABASE_URL",
"doc": "/develop/build/backend#database"
}Wiring is injected at named anchors — comment markers the generated code leaves for exactly this. capabilities.json lists them per file:
| File | Anchors |
|---|---|
api/src/application.ts | // veni:imports · veni:services · veni:controllers · veni:repositories |
api/src/services/index.ts | veni:services-barrel |
api/src/controllers/index.ts | veni:controllers-barrel |
Not every edit has an anchor. The storage and email recipes also insert at fixed lines the template ships: the AuthService import in application.ts, the APP_ENV_SERVER_BASE_PATH line of the env schema, BAD_REQUEST in ErrorCode, the APP_ENV_SHELL_GRPC_URL entry in the overlay, and the container's tmp mount in deployment.yaml. An app that has moved one of those lines gets a refusal that names it, not a silent skip.
A materialize handler copies its fragment and splices its bindings in at these markers — so nothing regex-rewrites arbitrary code.
Adding a capability
To extend what veni enable can grow:
- Declare it in
capabilities.json—title,summary,requires(its prerequisites),materialize(the handler name), and adoclink. - Author the fragment under
api/src/.recipes/<cap>/with the files it adds, using the placeholder tokens. - Implement the handler in
enable.ts— copy the fragment and inject bindings at the anchors above. - Let CI guard it —
scaffolder.test.tsassertscreate+ recipe convergence and recipe idempotency; add a case for the new capability.
Keep it additive: there is no subtractive path, and every capability must be safe to apply on top of the base.
The wizard
The UI's New remote app flow is a thin front-end over the same engine: it collects the name, the layers and the theme in the browser, then calls POST /api/builder/scaffold (dev-gated on APP_ENV_NODE_ENV=development + APP_ENV_BUILDER_ENABLED=true). Outside dev it just prints the equivalent veni create command. The user-facing four steps are in Build a satellite.
What a generated app looks like
orders/ # the scaffolded app
├─ api/src/ # Bun + Hono + Ignis
│ ├─ application.ts # composition root — the // veni: anchors live here
│ ├─ controllers/ # auth.controller + your endpoints (user with --db)
│ ├─ services/ # auth.service (token exchange), cache, jwt-token
│ ├─ utils/auth-guards.ts # requireAuth / requirePermission / ...
│ ├─ repositories/base.repository.ts # AuditedRepository
│ ├─ models/schema.ts # Drizzle tables
│ ├─ .recipes/database/ # the DB fragment — grafted by --db / enable database
│ ├─ .recipes/storage/ # StorageClientService — grafted by enable storage
│ ├─ .recipes/email/ # EmailClientService — grafted by enable email
│ └─ grpc/ + grpc-contracts/ # inbound Connect RPC + vendored contracts
└─ ui/src/ # React + Vite + Module Federation
└─ federation/shell-entry.tsx # the ./ShellEntry entryCommon snags
| Symptom | Fix |
|---|---|
create and enable database produce different trees | They must both go through applyDatabaseRecipe() — scaffolder.test.ts catches the drift. Don't fork the recipe. |
| A recipe corrupts an app on re-run | Recipes must be idempotent — guard every graft/splice; the convergence test asserts a second apply is a no-op. |
| A JSX double-brace expression got mangled | Token replacement matches only the named placeholders in the map; don't add a token whose name could appear in a JSX expression. |
See also
- Build a satellite — use the scaffolder to build a real app, end to end.
- Components — the registry the scaffolder's themes come from.
- The veni CLI —
veni create/veni enablefrom a user's side.