Custom Commands
You can add your own holo commands for one-off scripts and maintenance tasks: backfilling data, sending a report, cleaning up old files. Put a file in server/commands and Holo picks it up automatically.
Your first command
import { defineCommand } from '@holo-js/cli'
export default defineCommand({
description: 'Publish posts whose publish date has passed.',
async run() {
console.log('Publishing scheduled posts...')
},
})Run it:
npx holo posts:publish-scheduledIt also shows up under App commands in npx holo list.
Command names
When you don't set a name, Holo builds one from the file path inside server/commands. Folders become : segments and the file extension is dropped:
| File | Command |
|---|---|
server/commands/report.ts | holo report |
server/commands/posts/publish-scheduled.ts | holo posts:publish-scheduled |
server/commands/users/cleanup/inactive.ts | holo users:cleanup:inactive |
Set name to choose your own, and aliases for extra names:
import { defineCommand } from '@holo-js/cli'
export default defineCommand({
name: 'reports:daily',
aliases: ['report'],
description: 'Send the daily sales report.',
usage: 'holo reports:daily [--date YYYY-MM-DD]',
async run() {
console.log('Sending report...')
},
})Command names and aliases must be unique. If one clashes with a built-in command like list or migrate, holo stops with an error that names both commands.
Holo discovers .ts, .mts, .cts, .js, .mjs, and .cjs files. The command can be the default export or a named export.
Holo keeps a list of your commands, jobs, events, and listeners in .holo-js/generated. When you add, delete, rename, or edit one of those files, the next run of one of your commands updates that list, so you don't need to run holo prepare first.
Holo loads every command file to read its name and description. If a command file fails to load, for example because a module it imports is missing, the other commands still work. holo list shows the failed command under its file path name with Failed to load: and the error, and running it prints the same error. If another command already uses that name, holo leaves the failed command out. Once the file loads again, its custom name and aliases work again.
Arguments and flags
run receives a context object. Positional arguments are in context.args and flags are in context.flags:
import { defineCommand } from '@holo-js/cli'
export default defineCommand({
description: 'Give a user a role.',
usage: 'holo users:promote <email> [--role <role>] [--dry-run]',
async run({ args, flags }) {
const email = args[0]
if (!email) {
throw new Error('Pass the user email: holo users:promote <email>')
}
const role = typeof flags.role === 'string' ? flags.role : 'admin'
const dryRun = flags['dry-run'] === true
console.log(`${dryRun ? '[dry run] ' : ''}Promoting ${email} to ${role}`)
},
})npx holo users:promote [email protected] --role editor --dry-runFlags follow the same rules as built-in commands (see How arguments and flags work):
| You type | flags value |
|---|---|
--role editor or --role=editor | 'editor' |
--dry-run | true |
-v | { v: true } |
--tag a --tag b | ['a', 'b'] |
Flag values are always strings or true. Convert numbers yourself, for example Number(flags.limit).
Output and errors
Write output with console.log and console.error.
To fail a command, throw an error. Holo prints the error message and exits with code 1. When run finishes normally, the exit code is 0.
if (!email) {
throw new Error('Missing email.')
}Using the database and other services
Commands don't connect to your database or start other services until you ask. Wrap the work in context.withRuntime(). Inside the callback, Holo has loaded your config and connected services, so models, DB, mail, queues, and storage work as they do in your routes.
import { defineCommand } from '@holo-js/cli'
import Post from '../../models/Post'
export default defineCommand({
description: 'Publish posts whose publish date has passed.',
async run({ withRuntime }) {
const count = await withRuntime(async () => {
return await Post.where('status', 'scheduled').count()
})
console.log(`${count} scheduled posts found.`)
},
})When the callback finishes (or throws), Holo shuts the services down and closes the connections. The callback's return value is returned from withRuntime() with its type.
You can call withRuntime() more than once. Nested calls reuse the same connections.
A command can import the same app modules as your routes, including modules that import framework packages. For example, a service that imports next/headers loads in a command too.
Jobs and events you import work the same way. Inside withRuntime(), SendReport.dispatch(payload) runs the job on the sync driver or queues it on the database driver, as it would from a route.
The runtime object
The callback receives a runtime object with two members:
| Member | Description |
|---|---|
holo | The Holo runtime. Use holo.useConfig('app') or holo.config('app.name') to read config, and holo.projectRoot for the project path. |
migrate({ names, pretend }) | Run specific migrations by name. Returns the names that ran. With pretend: true, returns the names that would run without running them. |
import { defineCommand } from '@holo-js/cli'
export default defineCommand({
description: 'Print the app name and environment.',
async run({ withRuntime }) {
const app = await withRuntime(({ holo }) => holo.useConfig('app'))
console.log(`${app.name} (${app.env})`)
},
})The context object
| Property | Description |
|---|---|
args | Positional arguments, as strings. |
flags | Parsed flags. |
cwd | The folder you ran holo from. |
projectRoot | Your project's root folder. |
withRuntime(callback) | Start Holo services, run the callback, then shut them down. |
loadProject() | Load the Holo project settings, such as the configured folder paths. |
Definition options
| Option | Required | Description |
|---|---|---|
description | Yes | One line shown in holo list and --help. |
run(context) | Yes | The function to run. Can be async. |
name | No | Command name. Defaults to the name built from the file path. |
aliases | No | Extra names for the command. |
usage | No | Usage line shown by holo <command> --help. Defaults to holo <name>. |
Changing the commands folder
Commands live in server/commands by default. To use another folder, set paths.commands in config/app.ts. See Configuration Files.
Available functions
Defining a command
Context
Runtime object
defineCommand()
Declares a command. Export it as the default export of a file in server/commands. It accepts the definition options and returns the same object, frozen.
import { defineCommand } from '@holo-js/cli'
export default defineCommand({
description: 'Send the daily sales report.',
async run() {
console.log('Sending report...')
},
})args and flags
args holds the positional arguments as strings. flags holds the parsed flags. See arguments and flags.
const email = args[0]
const dryRun = flags['dry-run'] === truecwd and projectRoot
cwd is the folder you ran holo from. projectRoot is your project's root folder.
console.log(projectRoot, cwd)loadProject()
Returns the project settings, including the folder paths in config.paths. Use it when a command needs to find files such as models or migrations.
const { config } = await loadProject()
console.log(config.paths.commands)withRuntime()
Starts Holo services, runs your callback, and shuts the services down afterwards. It returns the callback's result. See using the database and other services.
const count = await withRuntime(() => Post.where('status', 'scheduled').count())holo
The Holo runtime, passed to the withRuntime() callback. Use holo.useConfig() or holo.config() to read config, and holo.projectRoot for the project path.
await withRuntime(({ holo }) => holo.config('app.name'))migrate()
Runs the named migrations and returns their names. With pretend: true, it returns the names that would run without running them. Pass the names of the migrations to run, such as create_posts_table.
await withRuntime(({ migrate }) => migrate({ names: ['create_posts_table'], pretend: true }))Next steps
- CLI Commands
- Using Holo Services
- Seeding — for repeatable test and demo data