Creating a Recipe
A recipe is a regular JavaScript or TypeScript file that registers tasks and inserts them into the pipeline upon import.
Basic structure
import { task, cd, run, after } from '@catapultjs/deploy'
task('my-recipe:build', () => {
cd('{{release_path}}')
run('npm ci')
run('npm run build')
})
after('deploy:update_code', 'my-recipe:build')The task function receives a ctx argument with the current host, paths, config, release, and logger. Use it when you need to access host information or run raw SSH commands:
import { task, after } from '@catapultjs/deploy'
import { ssh, q } from '@catapultjs/deploy/utils'
task('my-recipe:build', async ({ host, paths, release, logger }) => {
await ssh(host, `set -e\ncd ${q(paths.release)}\nnpm run build`)
logger.step(host.name, `build complete for ${release}`)
})
after('deploy:update_code', 'my-recipe:build')Importing the file is enough to activate it:
import './recipes/my-recipe'Setup hook
Use onSetup() to run server-side initialization during cata deploy:setup:
import { onSetup } from '@catapultjs/deploy'
import { ssh, q, getPaths } from '@catapultjs/deploy/utils'
onSetup(async (ctx, host, logger) => {
const paths = getPaths(host.deployPath, ctx.release)
await ssh(
host,
`
set -e
mkdir -p ${q(paths.shared + '/storage')}
if [ ! -f ${q(paths.shared + '/.env')} ]; then
touch ${q(paths.shared + '/.env')}
fi
`
)
logger.step(host.name, 'shared directories ready')
})Overriding built-in tasks
Recipes can override built-in tasks by registering the same name. This is how official recipes such as git, rsync, adonisjs, and astro customize deploy:update_code or deploy:build.
import { task } from '@catapultjs/deploy'
task('deploy:update_code', async ({ paths }) => {
console.log(`sync to ${paths.release}`)
})Status hook
Use onStatus() to display additional information during cata status:
import { onStatus } from '@catapultjs/deploy'
import { ssh } from '@catapultjs/deploy/utils'
onStatus(async (_ctx, host, logger) => {
const { stdout } = await ssh(host, `set +e\nmy-service --version || true`)
logger.log(`my-service ${stdout.trim() || 'unavailable'}`)
})Configuration: set() / get()
Expose configurable options via set() / get() so users can customize the recipe without modifying it:
import { task, run, get } from '@catapultjs/deploy'
task('my-recipe:deploy', () => {
const excludes = get<string[]>('my_recipe_excludes', [])
run(`rsync --exclude=${excludes.join(' --exclude=')} ...`)
})Users configure it in their deploy.ts:
import { set } from '@catapultjs/deploy'
import './recipes/my-recipe'
set('my_recipe_excludes', ['.git', 'node_modules'])Binaries: bin()
Use bin() to resolve binary paths. It checks the current host's bin config first, then falls back to the binary name:
import { task, cd, run, bin } from '@catapultjs/deploy'
task('my-recipe:build', () => {
cd('{{release_path}}')
run(`${bin('node')} my-script.js`)
})Users configure the path per host in deploy.ts:
hosts: [
{
name: 'production',
ssh: 'deploy@example.com',
deployPath: '/home/deploy/myapp',
bin: {
node: '/home/deploy/.nvm/versions/node/v22.14.0/bin/node',
php: '/usr/bin/php8.2',
},
},
]Contributing a recipe
If your recipe could be useful to others, you're welcome to contribute it to the project.
Open a pull request on GitHub and add your recipe file to the contrib/ directory.
TypeScript: extending TaskRegistry
To get type-safe task names, extend the TaskRegistry interface:
import type {} from '@catapultjs/deploy'
declare module '@catapultjs/deploy' {
interface TaskRegistry {
'my-recipe:build': true
'my-recipe:deploy': true
}
}Agent skill
The package ships a catapultjs skill that teaches this whole workflow (conventions, DSL, hooks, pipeline insertion) to Claude Code and any agent supporting the SKILL.md format. Copy it into your project to get assisted recipe writing:
mkdir -p .claude/skills
cp -r node_modules/@catapultjs/deploy/skills/catapultjs .claude/skills/See the Agent skill page for details.