alepha@docs:~/docs/guides/core$
cat 8-run-modes.md | pretty
9 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. 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.

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.

#$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, 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:

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

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

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.

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

ts
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