The recipe model

A recipe is an additive manifest rewrite. It takes an existing project's unibench.toml and adds a new service (or runtime, or both) — typically pointing at a folder the user already created or plans to create.

Recipes are deliberately not code generators. Applying frontend-vite doesn't create a Vite project for you — it adds the manifest declarations that wire one in, and leaves "actually have a Vite project in ./frontend" as your problem.

Why "additive only"?

The two failure modes a code generator hits regularly are:

  1. Stale templates. Six months later, the recipe's bundled package.json is three minor versions behind upstream and you carry someone else's outdated defaults forever.
  2. Conflicting opinions. Your Vite app uses Vue + Pinia; the recipe assumes React + Tanstack Query. Now you fight the recipe instead of using it.

The additive model sidesteps both. Recipes declare how to run your existing project under Unibench, not what your project looks like.

Apply semantics

unibench recipes apply <recipe> [<project>] [--service-name <n>] [--cwd <p>] reads the manifest with toml_edit (comments-preserving), merges the recipe's TOML fragment, and writes the result back.

The merge:

  • inserts new top-level entries ([services.<n>], [runtime.<lang>])
  • errors on service-name conflict — the existing service stays untouched; the manifest doesn't end up half-applied
  • preserves the user's pinned runtime version on conflict (we'd rather not silently bump someone's [runtime.node] from 20.11.0 to 22)
  • keeps every comment, blank line, and existing field order outside the mutation point

Service-name conflict is the one error users actually hit in practice — same-folder twice, or two recipes both wanting to be "frontend". --service-name admin solves it.

The v1 recipes

Two ship in v1:

  • frontend-vite — Vite dev server as a process service. npm run dev -- --port $PORT, default cwd ./frontend.
  • frontend-nextjs — Next.js dev server with -p $PORT forwarding.

Both default to service name frontend and cwd ./frontend. Apply the same recipe twice with different --service-name + --cwd to land an admin app alongside the customer-facing one.

What a recipe is, mechanically

Internally a recipe is:

  • a stable name and human-facing summary / description
  • tags for surfacing in UIs
  • a default_service_name and default_cwd
  • a TOML fragment template with {{SERVICE_NAME}} and {{CWD}} placeholders that get substituted at apply time

The fragment is just TOML — it goes through the same parser as the project's manifest, so the same validation applies. A broken recipe fails at apply time, not silently at start time.

Adding new recipes

Recipes live in crates/core/src/recipes.rs. Adding one means adding a Recipe to catalog() and shipping a new app build. v2 may move them out-of-process (decision 2.17) but the v1 set is small enough that hardcoding is the right call.

What recipes can't do

Recipes are intentionally bounded. They can't:

  • modify existing service blocks (conflict → error)
  • delete anything
  • run scripts on apply
  • prompt for user input beyond the CLI flags

If you want any of those, you probably want a seed script or a custom service-type, not a recipe.