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 contains DROP TABLE, DROP COLUMN, or ADD COLUMN NOT NULL without DEFAULT. 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 ALTER in production. Hotfix at 3am, no forward migration written. The next deploy's apply log 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 exists or relation "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 of cms migration generate.
  • migration.ts — exports up(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 apply as a pre-deploy step.
  • Online schema change. For very large tables that can't tolerate a brief lock, use a tool like pg_repack or gh-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