#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. Two 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 |
$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.
#$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, which help also answers as a word:
alepha help report prints exactly what alepha report --help prints, unless
the CLI registers a command named help of its own. A positional prints under
its schema's title (args: z.text({ title: "month" }) reads <month>), and
a flag's help line is its description. 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. An empty value is a value: --summary "" and --summary=
both pass "".
An array flag repeats. --tag a --tag b is ["a", "b"]: each occurrence is
cast against the element schema, so --count 1 --count 2 on a
z.array(z.integer()) is [1, 2]. An occurrence written as a JSON array is
spread, so --tag '["a","b"]' keeps working and --tag '["a","b"]' --tag c is
["a", "b", "c"]. A scalar flag given twice keeps the last value. The help line
of an array flag says (repeatable).
#Reading a value from a file or stdin
A shell cannot pass a Markdown body as a flag value: --body "## H\nBody" is the
two characters \ and n, not a newline. A flag that declares atFile: true
reads it from a file instead:
1import { z } from "alepha"; 2import { $command } from "alepha/command"; 3 4class NoteCommands { 5 create = $command({ 6 name: "create", 7 description: "Create a note", 8 flags: z.object({ 9 title: z.text({ description: "The note's title" }),10 body: z.text({11 size: "rich",12 atFile: true,13 description: "The note, in Markdown",14 }),15 tag: z.array(z.text()).describe("A tag").optional(),16 }),17 handler: async ({ flags, print }) => {18 print(19 `${flags.title}: ${flags.body.length} chars, ${flags.tag?.length ?? 0} tags`,20 );21 },22 });23}
alepha create --title "Plan" --body @plan.md --tag cli --tag docs
git log -1 --format=%B | alepha create --title "Last commit" --body @-
| Value | On a flag with atFile: true |
|---|---|
@path |
The content of the file, resolved against the command's root |
@- |
The whole of stdin |
@@text |
The literal @text |
| anything | Itself |
The file is read after the token is bound to its flag and before it is cast, so
a body that starts with - is still a value, and a file holding JSON reaches a
string flag as that string. On an array flag, the content goes through the
repeat rule once. An empty file is accepted, since it was named on purpose.
@- is stricter, because the failure it guards against is silent. It may be
used once per invocation. It is refused when stdin is a terminal, since waiting
on a keyboard nobody is at is a hang. And it is refused when stdin is empty,
since an agent's shell tool with nothing piped in has an empty stdin, and
sending an empty body without a word is how that goes unnoticed: pass "" to
mean empty.
atFile is opt-in, never implied by a string. alepha test --project takes
package names, and --project '@alepha/ui*' has to reach Vitest as typed. On a
flag without atFile, @ is just a character. The help line of a flag that
declares it says (takes @file, or @- for stdin; @@ for a literal @).
Stdin is read through ConsoleInputProvider; substitute MemoryInputProvider
in a spec, and MemoryFileSystemProvider for the files.
#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. They go to different
streams: print to stdout, and in a CLI every log line to stderr (see
Logging). 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.
#Failing, and exit codes
Throw a CommandError to fail a command. The CLI reports its message, and the
message of its innermost cause when that says something the message does not,
without a stack trace (--verbose keeps it), and exits non-zero. Any other
error is a crash and keeps its stack.
exitCode says why it failed, so a script can branch without parsing the
sentence:
1import { z } from "alepha"; 2import { $command, CommandError } from "alepha/command"; 3 4class SyncCommands { 5 sync = $command({ 6 name: "sync", 7 env: z.object({ SYNC_TOKEN: z.text().optional() }), 8 handler: async ({ env }) => { 9 if (!env.SYNC_TOKEN) {10 throw new CommandError("Not authenticated. Set SYNC_TOKEN.", {11 exitCode: 3,12 });13 }14 },15 });16}
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Generic failure: the default when a CommandError names none |
| 2 | Reserved: alepha i18n check found drift |
| 3 | Not authenticated, or the credential was not accepted |
| 4 | Forbidden |
A mistyped command, flag or argument is a UsageError, a CommandError of its
own: the reason, then the help, then exit 1.
#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.
#A key that depends on the flags
Pass a function instead, and it is called once the flags, args and env have been
parsed. Returning undefined takes no slot at all:
1verify = $command({2 flags: z.object({ fast: z.boolean().optional() }),3 exclusive: ({ flags }) => (flags.fast ? undefined : true),4 handler: async ({ run }) => {5 await run("yarn test");6 },7});
That is how one command offers both a serialised lane and a concurrent one
without splitting into two commands. It sees flags, args, env and root,
and nothing else: two processes have to compute the same key from the same
invocation, so the decision must be a pure function of the parsed input.
#Claims are reentrant
A command already inside a key may acquire it again. The inner claim takes no ticket, so it does not queue behind a slot this process already owns, and only the outermost release frees it.
#What frees a stuck slot
A ticket is swept when the process that wrote it is gone, which is read from the pid rather than from the age of its heartbeat. A holder starved of CPU keeps its slot however long it stalls for. That is the whole point: on a saturated machine the holder is exactly the process most likely to miss a heartbeat, and sweeping one meant two processes ended up inside the critical section at once.
SIGINT and SIGTERM hand the slot over immediately. SIGKILL cannot, so the
next arrival sweeps 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