@chamvi/cli
The chamvi bin: argv, entry loading, signals and exit codes for an app built with @chamvi/app. Each command only calls the app's lifecycle methods; see Apps & Modules.
npm install @chamvi/app @chamvi/cli ai
npm install --save-dev tsdown # only for chamvi buildNode.js >=22.18.0 (type stripping, so dev and a .ts entry run without a build step). @chamvi/app is a required peer dependency (one copy), tsdown an optional one, imported by build only.
package.json scripts of an agent repository:
{
"type": "module",
"scripts": {
"dev": "chamvi dev",
"build": "chamvi build",
"start": "chamvi start"
}
}Commands
chamvi <command> [args] [flags], plus --help and --version. Every command takes --entry <path>.
| Command | Arguments and flags | Does | Exit |
|---|---|---|---|
dev | --entry | Spawns node --watch [--env-file-if-exists=.env] <cli.mjs> start --dev --entry <source entry> (the env flag only when .env exists at start: on Linux node --watch fails on a missing env file), forwards SIGINT and SIGTERM | the child's code |
build | --entry, --out-dir <dir> (default dist) | Bundles the entry with tsdown (missing: exit 2, "install tsdown as a devDependency"); the out dir is emptied first, so one that is the project folder or above it, holds the entry, or holds anything but .mjs and .mjs.map files exits 2 | 0, 1 build error, 2 usage |
start | --entry, --dev (hidden) | app.start(), then waits for a signal | 0 clean stop, 1 start failure or forced exit |
run-job | <agent>, [triggerId], --entry | app.runJob({ agent, trigger }), prints <agent>: <status> (run <id>) (also after a signal); triggerId defaults to the agent's only cron trigger | 0 completed, waiting, skipped, queued behind an approval or interrupted by a signal; 1 failed or cancelled (also when a signal came meanwhile), an error or a failed stop; 2 usage |
migrate | --entry | app.migrate() | 0, 1, 2 (DATABASE_URL unset is 2) |
Entry: --entry <path> (a bare --entry or --no-entry exits 2), else the command's default. The source commands (dev, build, start --dev) read package.json "chamvi": { "entry": "<path>" }, then fall back to src/index.ts. The built commands (start, run-job, migrate) never read that field and default to dist/index.mjs, so an image built from a repository that sets it still starts; a missing one exits 2 with "run chamvi build or pass --entry src/index.ts". The entry is loaded with import(); a default export that is not an app exits 2 with <entry> must export default createApp({...}).
start --dev (what dev runs): the source entry, <cwd>/.env (only the keys the env lacks, through node:util parseEnv; loaded before the entry is imported, so the optional module factories see them), a MemoryStore unless DATABASE_URL is set, a random RESUME_SECRET when it is unset and, without any authenticator, one random * token with id dev, each logged once with a warning; CHAMVI_DRAIN_TIMEOUT_MS defaults to 5000 and CHAMVI_FORCE_EXIT_MS to 15000.
Exit codes
const EXIT_CODE: { OK: 0; FAILURE: 1; USAGE: 2 };
const CLI_USAGE_ERROR_CODE: 'cli_usage';| Code | When |
|---|---|
0 | Success: a clean stop, a built bundle, a migrated store, a job as listed above |
1 | Runtime failure: a start, build or migrate error, a failed or cancelled job, a forced exit after CHAMVI_FORCE_EXIT_MS or a second signal |
2 | Usage or configuration: an unknown command or flag, an entry that is missing or no chamvi app, missing or invalid env (named, its value never echoed), a model string whose provider package or key is missing, a module graph error (a dependency the app does not register, a cycle), any InvalidAgentDefinitionError before anything started (the engine starts only once every module checked its configuration) |
The CLI sets process.exitCode and lets Node exit; process.exit() only ends a forced exit. An unknown command or a missing argument prints the usage, every other exit-2 error its message, an exit-1 error its stack.
Signals
start and run-job:
- The first SIGTERM or SIGINT calls
app.stop()(forrun-job: aborts the job's wait throughrunJob({ signal }), which then drains), then exits 0, or 1 when the stop reported an error. - A second signal exits 1 at once. A signal within 1 s of the first counts as the same one, since
npm run devandnpm startdeliver one Ctrl-C twice (npm forwards SIGINT to the script while the terminal signals the whole group). CHAMVI_FORCE_EXIT_MS(90 s) after the first signal, a stop still running exits 1.
migrate installs no handlers: Node's default ends it.
On Kubernetes keep preStop + CHAMVI_FORCE_EXIT_MS < terminationGracePeriodSeconds. With a 5 s preStop sleep and a grace period of 120 s, the worst case is 5 + 0 (readiness delay) + 30 (drain) + 5 (close) + 30 (workspaces) = 70 s, the forced exit comes at 5 + 90 = 95 s and SIGKILL at 120 s.
dev and process groups
A terminal's Ctrl-C sends SIGINT to the whole foreground process group, and the node --watch supervisor forwards the signal it receives to its child. Without care the app would get two signals and the second-signal rule would exit 1 without draining. So dev spawns the supervisor detached, in a process group of its own: the terminal's SIGINT reaches only the CLI, which forwards that one signal. SIGHUP (the terminal closed) and the CLI's own exit stop the child with SIGTERM, and the child's stdin is a pipe only the CLI writes: when the CLI dies without its exit hook (SIGKILL, the OOM killer), the pipe closes and the app ends the whole group, so it never outlives the CLI. The exit code is the child's, 0 when it ended by a forwarded signal.
node --watch restarts on a change of the entry, anything it imports, and .env (Node watches the env file it loaded), so dev passes no --watch-path.
build output
chamvi build calls tsdown with:
{
config: false,
entry: { index: '<entry>' },
format: 'esm',
platform: 'node',
target: 'node22',
outDir: '<out-dir>',
fixedExtension: true,
sourcemap: true,
dts: false,
clean: true,
}The result is <out-dir>/index.mjs plus its source map. Every dependencies and peerDependencies package stays external and the app's own modules are inlined; @chamvi/*, typeorm, pg, dockerode and @kubernetes/client-node are never bundled (@chamvi/ui finds its dist/app through import.meta.url). An image runs dist/index.mjs with the production node_modules: CMD ["node", "node_modules/@chamvi/cli/dist/cli.mjs", "start"].
Programmatic use
function runCli(argv: string[], context?: Partial<CliContext>): Promise<void>;The bin is await runCli(process.argv.slice(2), processContext(...)). Every process touch point is injectable, which is how the CLI's own tests run commands in-process:
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
import type { spawn } from 'node:child_process';
import type { Env, IClock } from '@chamvi/app';
import type { ISignalTarget } from '../signals/types.ts';
/** Everything a command touches of the process, injectable for tests. */
export type CliContext = {
cwd: string;
env: Env;
clock: IClock;
/** Where SIGTERM and SIGINT arrive (`process`). */
signals: ISignalTarget;
/** Sets the exit code the process ends with (`process.exitCode`). */
setExitCode(code: number): void;
/** Ends the process at once; only for the forced exit. */
exit(code: number): void;
stdout(text: string): void;
stderr(text: string): void;
/** Default `(specifier) => import(specifier)`. */
importModule(specifier: string): Promise<unknown>;
spawn: typeof spawn;
/** `process.execPath`. */
execPath: string;
/** The bin, `dist/cli.mjs`. */
cliPath: string;
/**
* Under `chamvi dev` (stdin is a pipe only the dev CLI writes): once that
* pipe closes, the CLI died without forwarding a signal, so this ends the
* whole dev process group (the `node --watch` watcher and this process)
* with SIGTERM.
*/
linkToParent(): void;
};type ProcessEvent = 'SIGTERM' | 'SIGINT' | 'SIGHUP' | 'exit';
/** Where signals arrive: `process`, or a fake in tests. */
interface ISignalTarget {
on(event: ProcessEvent, fn: () => void): void;
off(event: ProcessEvent, fn: () => void): void;
}