#Run Modes and Commands
Most of the time an Alepha app is a server. Sometimes the same codebase has to be something else for thirty seconds: apply migrations, seed a database, print a report, drain a queue. Three primitives cover that without a second entry point and without a second copy of your wiring.
| Primitive | Import | Triggered by | Good for |
|---|---|---|---|
$mode |
alepha |
An environment variable | One-shot tasks in the deployed image |
$seed |
alepha/orm |
SEED=true |
Filling a database, transactionally |
$command |
alepha/command |
A CLI argument | Tooling humans invoke by name |
#$mode
$mode gates a whole bootstrap on an environment variable. When it matches, the
declaring class becomes alepha.target and the dependency graph is pruned to
that class and its transitive dependencies. The HTTP server, the job
scheduler, the cron ticker: none of them are constructed, because nothing in the
kept subgraph asks for them.
1import { $inject, $mode } from "alepha"; 2import { DatabaseProvider } from "alepha/orm"; 3 4class DbMigrationMode { 5 db = $inject(DatabaseProvider); 6 7 mode = $mode({ 8 env: "MIGRATE", 9 ready: async () => {10 await this.db.migrate();11 },12 });13}
MIGRATE=true node app.js
MODE=MIGRATE node app.js does the same thing. Two spellings because two
callers want different shapes: a Kubernetes init container sets one variable per
job, while a single-variable MODE suits a container image that takes one
argument.
That pruning is the point. A migration container that boots the HTTP server has to bind a port it will never serve, and a seed job that starts the cron ticker fires whatever was due. Running the task inside the real container, with the real DI graph and the real configuration, and nothing else, is what makes this different from a script that imports your services.
After ready resolves or throws, alepha.stop() runs, so connections close and
the process exits. Omit ready and the mode still prunes the graph but the
process stays alive, which is what a queue worker or a cron-only deployment
wants.
$mode returns a boolean, so a class can branch on whether it is the active
mode.
#MIGRATE=false does not activate it
The check is isEnvEnabled, not truthiness. MIGRATE=false, MIGRATE=0 and
MIGRATE= all leave the mode off. This is worth stating because the naive
version of this check treats every non-empty string as true, and MIGRATE=false
then runs your migrations.
#$seed
$seed is $mode({ env: "SEED" }) with the handler wrapped in a database
transaction:
1import { z } from "alepha"; 2import { $entity, $repository, $seed, db } from "alepha/orm"; 3 4const user = $entity({ 5 name: "users", 6 schema: z.object({ 7 id: db.primaryKey(z.uuid()), 8 name: z.text(), 9 }),10});11 12class AppSeed {13 users = $repository(user);14 15 seed = $seed({16 handler: async () => {17 await this.users.create({ name: "John Doe" });18 },19 });20}
SEED=true node app.js
The transaction is the whole reason it exists as its own primitive. A seed that throws on row 400 rolls back the 399 before it, so a failed seed leaves an empty database rather than a half-populated one that the next run then duplicates.
Everything true of $mode is true here: the graph is pruned, the app stops when
the handler finishes.
#$command
$command declares a CLI command: its name, its flags, its arguments, the
environment it requires, and its handler.
1import { z } from "alepha"; 2import { $command } from "alepha/command"; 3 4class ReportCommands { 5 report = $command({ 6 name: "report", 7 description: "Render a usage report", 8 args: z.text(), 9 flags: z.object({10 format: z.enum(["json", "csv"]).default("json"),11 verbose: z.boolean().default(false),12 }),13 env: z.object({14 REPORT_TOKEN: z.text({ description: "API token for the report service" }),15 }),16 handler: async ({ args, flags, env, print }) => {17 print(`${args} as ${flags.format} with ${env.REPORT_TOKEN.length} chars`);18 },19 });20}
Register the class in your alepha.config.ts and it is reachable through the
alepha CLI:
1import { defineConfig } from "alepha/cli/config";2 3export default defineConfig({4 services: [ReportCommands],5});
alepha report monthly --format=csv --verbose
Everything on the declaration does double duty. flags and args are parsed
and validated and printed in --help. env is validated before the handler
runs, so a missing token is a clear failure at second zero rather than a
undefined three API calls in.
#Flag syntax
--name value and --name=value are equivalent. A boolean flag needs no value:
--compile turns it on, and --no-compile or --compile=false turns it off. A
bare -- ends flag parsing, so everything after it is an argument even when it
starts with a dash.
#What the handler gets
| Field | Is |
|---|---|
flags |
Parsed and validated against flags |
args |
Parsed and validated against args |
env |
Validated against env, guaranteed present |
run |
Runs a labelled step, or a shell command, with progress reporting |
ask |
Interactive prompts |
print |
Writes a line to stdout |
fs |
node:fs/promises |
glob |
node:fs/promises' glob |
root |
The directory the command is running in |
help |
Prints this command's help |
print is not the logger, and the distinction matters. Output is what a
command produces; the logger is what it reports. Anything a caller might
pipe, parse or redirect goes through print. Sending it to the logger instead
is how alepha --version once answered 18:21:36 I Alepha v0.24.0, in colour,
in a shape that changed with LOG_FORMAT: an environment variable the calling
script does not control. print strips colour when stdout is not a TTY, so a
coloured string is still safe to pipe.
#Subcommands
children turns a command into a parent:
1import { $command } from "alepha/command"; 2 3class PublishCommands { 4 vercel = $command({ 5 description: "Deploy to Vercel", 6 handler: async ({ print }) => print("vercel"), 7 }); 8 9 cloudflare = $command({10 description: "Deploy to Cloudflare",11 handler: async ({ print }) => print("cloudflare"),12 });13 14 publish = $command({15 description: "Publish the application",16 children: [this.vercel, this.cloudflare],17 handler: async ({ help }) => help(),18 });19}
alepha publish vercel runs the child; alepha publish runs the parent
handler, which here prints the help rather than guessing.
#Hooks
pre and post attach a command to another one by name. They are hidden from
help, cannot be invoked directly, and receive the same parsed flags and
arguments as their target.
1import { $command } from "alepha/command"; 2 3class BuildCommands { 4 build = $command({ 5 name: "build", 6 handler: async ({ print }) => print("building"), 7 }); 8 9 prebuild = $command({10 pre: "build",11 handler: async ({ run }) => {12 await run("cleaning dist", async () => {});13 },14 });15}
#exclusive
exclusive: true gives the command a machine-wide slot. A second process
running the same command queues rather than failing, and reports who is
holding the slot while it waits. The slot covers the pre-hooks, the handler and
the post-hooks as one unit.
The key is derived from the package name at the command's root plus the command name, so several git worktrees of one project share a slot while unrelated projects never block each other. Pass a string to set the key yourself, which is also how two different commands come to share one.
It is one machine, not a cluster. ALEPHA_NO_EXCLUSIVE=1 bypasses it.
#mode
mode: true adds a --mode, -m flag that loads environment files the way Vite
does: .env and .env.local always, plus .env.<mode> and .env.<mode>.local
when a mode is given. Pass a string instead of true to set a default, so a
publish command can load production files without anyone typing
--mode production.
#See also
- Migrations for the migration commands that ship with the CLI
- The Alepha Instance for the lifecycle
$modeis pruning