Writing a Recipe
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
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:
{
"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.
| Helper | Does |
|---|---|
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, setConfig | package.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: [] });