03 / Develop · Veni builder · Scaffold

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:

  1. Copies vendor/app-template/ into the target dir.
  2. 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.
  3. Applies the database recipe when --db is set (see below).

The markers, from one ordered map:

text
{{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/src ships an auth.service.ts that verifies Shell tokens but has no database, no user.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-wires application.ts, the barrels and .env in place, guarded so a re-run is idempotent.
  • One recipe, two entry points. veni create --db and veni enable database both call applyDatabaseRecipe() — the single source of truth the convergence test pins.
  • Storage is a recipe too. api/src/.recipes/storage/ holds StorageClientService and its test. applyStorageRecipe() (veni enable storage) copies them and wires application.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/ holds EmailClientService and its test; applyEmailRecipe() (veni enable email) copies them and wires application.ts, the barrel and ErrorCode. 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:

json
"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:

FileAnchors
api/src/application.ts// veni:imports · veni:services · veni:controllers · veni:repositories
api/src/services/index.tsveni:services-barrel
api/src/controllers/index.tsveni: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:

  1. Declare it in capabilities.json — title, summary, requires (its prerequisites), materialize (the handler name), and a doc link.
  2. Author the fragment under api/src/.recipes/<cap>/ with the files it adds, using the placeholder tokens.
  3. Implement the handler in enable.ts — copy the fragment and inject bindings at the anchors above.
  4. Let CI guard it — scaffolder.test.ts asserts create + 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
text
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 entry

Common snags ​

SymptomFix
create and enable database produce different treesThey must both go through applyDatabaseRecipe() — scaffolder.test.ts catches the drift. Don't fork the recipe.
A recipe corrupts an app on re-runRecipes must be idempotent — guard every graft/splice; the convergence test asserts a second apply is a no-op.
A JSX double-brace expression got mangledToken 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 enable from a user's side.