Writing a Recipe

Ship a recipe with your module so vercube add wires it into a project

A recipe is what vercube init and vercube add apply to wire a module into a project: its dependencies, its plugin, an example, the environment it reads and the services it needs. The built-in modules have one each, and a package can ship its own at <package>/recipe:

$ vercube add @acme/vercube-search

A recipe

src/recipe.ts
import { defineRecipe } from '@vercube/cli/recipe';

const CONTROLLER = `import { Controller, Get, QueryParam } from '@vercube/core';
import { Inject } from '@vercube/di';
import { SearchClient } from '@acme/vercube-search';

@Controller('/api/search')
export default class SearchController {
  @Inject(SearchClient)
  private gSearch!: SearchClient;

  @Get('/')
  public async search(@QueryParam({ name: 'q' }) q: string): Promise<unknown> {
    return this.gSearch.query(q);
  }
}
`;

export default defineRecipe<{ engine: string }>({
  id: 'search',
  label: 'Search',
  prompts: [
    {
      name: 'engine',
      flag: 'search-engine',
      message: 'Search engine?',
      default: 'meilisearch',
      options: [
        { value: 'meilisearch', label: 'Meilisearch' },
        { value: 'typesense', label: 'Typesense' },
      ],
    },
  ],
  apply: ({ project, answers }) => {
    project.addDependencies({ '@acme/vercube-search': '^1.0.0' });
    project.addPlugin({
      name: 'SearchPlugin',
      from: '@acme/vercube-search',
      options: `{ engine: '${answers.engine}', url: process.env.SEARCH_URL }`,
    });
    project.createFile('src/controller/SearchController.ts', CONTROLLER);
    project.bind({
      name: 'SearchController',
      from: '@/controller/SearchController',
      default: true,
    });
    project.addEnv({ SEARCH_URL: 'http://127.0.0.1:7700' }, 'Search');
    if (answers.engine === 'meilisearch') {
      project.addComposeService('meilisearch', {
        image: 'getmeili/meilisearch:v1.15',
        ports: ['127.0.0.1:7700:7700'],
      });
    } else {
      project.addComposeService('typesense', {
        image: 'typesense/typesense:29.0',
        ports: ['127.0.0.1:8108:8108'],
      });
    }

    project.note('Start the search engine with `docker compose up -d`');
  },
});

Export it from the package, and depend on @vercube/cli as a peer, since the project already has it:

package.json
{
  "exports": {
    ".": "./dist/index.mjs",
    "./recipe": "./dist/recipe.mjs"
  },
  "peerDependencies": {
    "@vercube/cli": "^1.5.0"
  }
}

vercube add @acme/vercube-search loads the recipe from the project's node_modules, so the package is installed first. The recipe is added under the package name, whatever id it declares, and its questions take flags like the built-in ones: --search-engine typesense.

What a recipe can do

Every helper on project checks what is there before it changes anything. That is the one rule a recipe has to keep: applying it twice must change nothing, because vercube add runs on projects that already have it.

HelperDoes
addDependencies(deps, { dev })Adds to the app's package.json, keeping what is there; @vercube/* is pinned to the CLI's version
addPlugin({ name, from, options, imports })Imports the plugin and adds withPluginOptions(Plugin, options) to plugins in vercube.config.ts
bind({ name, from, default })Imports a class and adds container.bind(Class) to the container setup
addToContainer(code, imports)Adds a statement to the container setup, such as a storage mount
addToEntry(code, { position, imports })Adds a statement to src/index.ts, before createApp() or at the end
createFile(path, content)Creates a file, never overwriting one
addEnv(vars, comment, example)Adds to .env and .env.example; example keeps a generated secret out of the committed file
addComposeService(name, service)Adds a service to docker-compose.yml at the project root
addScript, addLines, updateJson, setConfigpackage.json scripts, lines of a text file, any JSON file, a top-level config option
allowBuilds(packages)Approves install scripts for pnpm, which stops an install with an unapproved one
note(text)A line for the summary

Options and statements are code, passed as strings and parsed: a recipe never concatenates into a source file. When a project does not look as expected, a helper throws a RecipeError that says what to add by hand. A recipe that throws, for that or any other reason, has every file of the run put back as it was, including what earlier helpers and recipes of the same run changed.

Depending on other recipes

requires adds another recipe and applies it first; the summary names what was added. after only orders, for a recipe that adapts when the other one is there. Both take the ids of built-in modules (a recipe from another package is added by naming that package in the same command), and requires can depend on the answers:

defineRecipe<{ engine: string }>({
  id: 'search',
  label: 'Search',
  prompts: [/* the engine question above */],
  requires: (answers) => (answers.engine === 'meilisearch' ? ['cache'] : []),
  after: ['telemetry'],
  apply: ({ project, selected }) => {
    if (selected.has('telemetry')) {
      // add a span around queries
    }
  },
});

Testing it

applyRecipes from @vercube/cli/recipe applies recipes to a directory without the CLI. Apply yours to a copy of the starter twice, and check the second run changed nothing:

import { applyRecipes, Project } from '@vercube/cli/recipe';
import recipe from '../src/recipe';

const first = await applyRecipes(
  new Project(dir, { version: '1.5.0' }),
  ['search'],
  [recipe],
);
const second = await applyRecipes(
  new Project(dir, { version: '1.5.0' }),
  ['search'],
  [recipe],
);

expect(second).toMatchObject({ created: [], updated: [], dependencies: [] });