Skip to content

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 ​

ts
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:

bash
npx holo posts:publish-scheduled

It 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:

FileCommand
server/commands/report.tsholo report
server/commands/posts/publish-scheduled.tsholo posts:publish-scheduled
server/commands/users/cleanup/inactive.tsholo users:cleanup:inactive

Set name to choose your own, and aliases for extra names:

ts
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:

ts
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}`)
  },
})
bash
npx holo users:promote [email protected] --role editor --dry-run

Flags follow the same rules as built-in commands (see How arguments and flags work):

You typeflags value
--role editor or --role=editor'editor'
--dry-runtrue
-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.

ts
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.

ts
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:

MemberDescription
holoThe 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.
ts
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 ​

PropertyDescription
argsPositional arguments, as strings.
flagsParsed flags.
cwdThe folder you ran holo from.
projectRootYour 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 ​

OptionRequiredDescription
descriptionYesOne line shown in holo list and --help.
run(context)YesThe function to run. Can be async.
nameNoCommand name. Defaults to the name built from the file path.
aliasesNoExtra names for the command.
usageNoUsage 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.

ts
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.

ts
const email = args[0]
const dryRun = flags['dry-run'] === true

cwd and projectRoot ​

cwd is the folder you ran holo from. projectRoot is your project's root folder.

ts
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.

ts
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.

ts
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.

ts
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.

ts
await withRuntime(({ migrate }) => migrate({ names: ['create_posts_table'], pretend: true }))

Next steps ​

Released under the MIT License.