alepha@docs:~/docs/framework/guides/core$
cat 8-run-modes.md | pretty
6 min read
Last commit:

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

typescript
 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}
bash
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:

typescript
 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}
bash
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.

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

typescript
1import { defineConfig } from "alepha/cli/config";2 3export default defineConfig({4  services: [ReportCommands],5});
bash
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:

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

typescript
 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