CMS CLI
All commands exposed by the `cms` binary.
The cms binary is installed by @cms/engine. It loads cms.config.ts (or cms.config.tsx) from the current working directory — override with CMS_CONFIG_PATH. Commands fall into four groups: server (run the app), migration (manage the database schema), user (manage admin accounts), and key (manage API keys).
cms <command> [args]
Run cms with no arguments to see grouped help.
Server
cms dev
Starts the dev server on a single port (default 4000). Serves the API at /api, the admin UI at /admin with HMR, and proxies between the two so cookies and CORS are not in the way during local work.
cms build
Builds the admin SPA to .cms/admin-dist/. The output is the static bundle that cms start serves at /admin.
cms start
Production entry point. Loads the config, connects to the database, and serves the API + the pre-built admin bundle on PORT (default 4000). Never runs migrations — see cms migration apply.
Migrations
The migration system diffs your code-defined schema against the latest snapshot and produces SQL or TypeScript migrations you commit to your repo. Each applied migration is recorded in the _migrations journal table with a SHA-256 checksum.
cms migration push
Dev-only. Bypasses migration files and pushes the config-defined schema directly via drizzle-kit pushSchema. Accepts data-loss changes without prompting. Do not use against production.
cms migration generate [name]
Diffs the current config against the latest snapshot. Writes a new migration to migrations/<YYYYMMDDHHMMSS>_<name>/ containing migration.sql and snapshot.json. UTC timestamps keep multiple engineers on different branches from colliding on the same name.
Output format is always SQL — there is no flag to emit TypeScript. If you need a migration.ts (data backfill, JS-only logic), generate the SQL migration first, then either rename the file and rewrite it as a TS module, or write a separate TS migration by hand. See .ts data migrations below.
Flags:
--check— does not write any files. Exits with code 1 if a migration would have been generated. Auto-resolves ambiguous JSONB renames as drop+add (the safe default). Designed for CI as a "did you forget to commit a migration?" guard.--accept-data-loss— required when the generated SQL containsDROP TABLE,DROP COLUMN, orADD COLUMN NOT NULLwithoutDEFAULT. Without it, generate refuses and prints the offending statements.
cms migration apply
Applies pending migrations in order. Always operator-driven — never auto-runs on boot.
Each migration runs inside a transaction by default, serialized via a Postgres advisory lock so concurrent invocations from multiple pods don't race. The journal table _migrations records each applied migration's name and checksum.
Before applying pending migrations, apply runs a drift check against the latest applied snapshot (not the latest on disk — comparing against that would always flag the tables the pending migration is about to create). Any divergence is printed as a warning and apply still proceeds.
The check is non-blocking on purpose. apply is often the thing that resolves drift — a forward migration written to reconcile a manual change — so refusing to run would block the fix. It earns its keep in three scenarios CI can't catch:
- Manual
ALTERin production. Hotfix at 3am, no forward migration written. The next deploy'sapplylog surfaces the divergence instead of letting it stay invisible until a query fails on a column that isn't where the code expects. - Environment skew. Staging and prod were meant to be identical and aren't. You see it in the deploy log of whichever environment ran apply.
- Pre-failure diagnostics. When a pending migration crashes with
column "x" already existsorrelation "y" does not exist, the drift report printed seconds earlier explains why.
CI's cms check-drift only sees the database CI builds for itself, so it can't detect drift that exists in real environments. Use it as the hard gate on intent (snapshot matches code); the apply-time warning observes reality (live DB matches the last thing we told it to be).
cms migration status
Lists all migrations with a ✓ (applied) or ○ (pending) marker. Pending migrations are tagged with their kind (sql / ts) and no-transaction if applicable, so operators can preview what will run.
cms migration check-drift
Introspects the live database and compares against the latest snapshot. Reports unexpected/missing tables and columns. Exits with code 1 if any drift is detected. Catches "someone ran ALTER TABLE by hand in production."
Migration files
Each migration lives in its own numbered directory containing exactly one of:
migration.sql— pure DDL/DML. The default output ofcms migration generate.migration.ts— exportsup(db)for data transforms or schema work that needs JavaScript. Both share the same numbering, journal, and checksum semantics.
migrations/
20260520143025_initial/
migration.sql
snapshot.json
20260521091400_backfill_slugs/
migration.ts
snapshot.json
.ts data migrations
import { sql } from "drizzle-orm";
import { batched } from "@cms/engine/migrate-helpers";
export const noTransaction = true; // required when using batched
export async function up(db) {
await batched(db, {
query: async (d) => {
const r = await d.execute(sql`
SELECT id, title FROM posts
WHERE slug IS NULL
ORDER BY id LIMIT 500
`);
return r.rows;
},
apply: async (rows, tx) => {
for (const row of rows) {
await tx.execute(sql`
UPDATE posts SET slug = ${slugify(row.title)} WHERE id = ${row.id}
`);
}
},
});
}
The batched helper commits per batch. Make the WHERE clause idempotent — a crash mid-run resumes naturally on re-apply because already-processed rows no longer match the predicate.
Header directives
Lines like -- migrate:<directive> at the top of a migration.sql file change apply behavior. TS migrations use export const.
-- migrate:no-transaction
Opts out of the per-file transaction wrapper. Required for statements Postgres can't run inside a tx (CREATE INDEX CONCURRENTLY, VACUUM, REINDEX CONCURRENTLY).
In a TS migration: export const noTransaction = true;.
Trade-off: if the DDL succeeds but the journal write fails, the schema is changed but unrecorded. Use IF NOT EXISTS / IF EXISTS in such migrations so a retry doesn't fail on the duplicate.
apply enforces this: a no-transaction migration with an unguarded CREATE TABLE, DROP TABLE, CREATE INDEX, DROP INDEX, ADD COLUMN, or DROP COLUMN is refused before execution. Add IF [NOT] EXISTS to clear the check.
-- migrate:lock-timeout <duration>
Overrides the default lock_timeout of 10s. Pass any Postgres duration (30s, 2min) or 0 to disable. The shorter the timeout, the faster a contended DDL fails — preferable to blocking writes indefinitely.
-- migrate:lock-timeout 30s
ALTER TABLE posts ADD COLUMN category text;
CI integration
# Catches "config changed but migration not committed" at PR time.
- run: pnpm cms migration generate --check
# Optional: catches manual ALTER drift before deploy.
- run: pnpm cms migration check-drift
Schema snapshot format
Each migration writes a snapshot.json describing the resolved schema at that point. Snapshots are JSON and human-reviewable. Each field is serialized with its base type, storage strategy, and column/relation metadata:
{
"version": 1,
"entities": [
{
"name": "posts",
"translatable": false,
"fields": [
{ "name": "title", "type": "text", "base": "text", "storage": "column", "required": true },
{ "name": "author", "type": "relation", "to": "authors", "storage": "relation" }
]
}
]
}
Safety semantics summary
| Concern | Where it's handled |
|---|---|
| Concurrent applies | Postgres advisory lock around the whole apply loop |
| Partial migration failures | Per-migration transaction wrap (opt out with migrate:no-transaction) |
| Re-apply safety in no-tx migrations | apply refuses unguarded CREATE/DROP TABLE, CREATE/DROP INDEX, ADD/DROP COLUMN |
| Tampered migration files | SHA-256 checksum on every applied row; mismatch on re-apply throws |
| Long-running DDL blocking production | Default lock_timeout=10s (override per-file) |
| Manual ALTER in the live DB | Drift check at apply (warn) + cms migration check-drift (CI, hard gate) |
| Destructive operations | cms migration generate refuses without --accept-data-loss |
| Config out of sync with migrations | cms migration generate --check (CI) |
| Live DB out of sync with migrations | cms migration check-drift |
| Auto-apply on boot | Disabled by design — always an operator step |
What's deliberately not supported
- Down migrations. Reverting via
down()gives false confidence and rarely runs cleanly in production. Write a new forward migration that undoes the change instead. - Auto-apply on boot. Two pods rolling out simultaneously is a real failure mode; the advisory lock would serialize them, but visibility matters more. Run
cms migration applyas a pre-deploy step. - Online schema change. For very large tables that can't tolerate a brief lock, use a tool like
pg_repackorgh-ost. Out of scope here.
User management
cms user create <email>
Creates a new user with the configured admin role, or promotes an existing user with that email to admin. Useful for seeding the first operator and onboarding new admins without writing SQL.
cms user create alice@example.com
Requires a role with isAdmin: true to be defined in your config. See Permissions.
API keys
cms key create
Generates a new random API key, inserts it into the database, and prints the raw value. The raw value is only shown once — store it immediately.
cms key create
cms key create --name "CI read key" --role editor
Flags:
--name <name>— display name for the key (default:CLI key)--role <role>— role to assign (default: the configured admin role)
cms key create --raw <key>
Upserts a specific raw key value — idempotent. If a key with that hash already exists it is left unchanged; otherwise it is inserted. Useful for scripted local setups and CI where you need a known key value.
cms key create --raw cms_mysecretkey --name "Seed key"
Previous
Config Reference