Skip to content

CLI Reference

plumix <command> [options] is the whole surface. Eight commands ship with the package itself; a runtime adapter contributes the rest — which is why deploy, preview and types exist on Cloudflare and not on Node, and why plumix --help groups them separately.

plumix migrate, plumix sync, plumix import, plumix cron, plumix meta, plumix mail, plumix doctor and plumix i18n are built in: they run the same way regardless of which runtime a site configures. dev, build, and on some runtimes deploy and types come from RuntimeAdapter.commandsModule instead, so their behaviour is the adapter’s to define. The same module tells migrate, meta and mail how to open the runtime’s database and declares the target options that name its deployed databases, and gives sync and import their transport. Runtime Adapters covers commandsModule from the package-author side.

plumix --help prints every command it can currently resolve, grouped by where it came from: a project with no plumix.config.ts in reach sees only the built-ins, because loading the runtime’s commands means loading the config first. Under them it lists the runtime’s target options and the commands that take them.

plumix --help from a project root is the fastest way to see what applies to it: the built-ins always, and — once a config loads — the runtime’s own commands beside them, grouped under its name.

Terminal window
plumix --help

plumix <command> --help (or -h, anywhere after the command name) prints that command’s usage, its one-line description and the runtime’s target options, then exits without building, migrating or running the command.

Terminal window
plumix deploy --help

Starts the dev server. What it wraps is the runtime’s: on Cloudflare it is Vite with @cloudflare/vite-plugin, and it accepts --port and --inspector-port; on Node it is Vite alone, and it accepts --port and --host. Neither runtime has the CLI build the app up front — dev opts out via deferApp on both, so a broken config renders as a boot-error page in the browser instead of aborting the terminal before the server is up.

Before the server starts, dev migrates the local database. When the site’s own tables are ahead of its migrations/, it writes the next migration there, as migrate generate does, and prints the file to commit. It then applies every owner’s pending migrations and then every pending data migration, as plumix migrate does, adopting a database set up by an earlier release. When drizzle-kit needs a rename answered and has no terminal to ask in, dev stops with the same advice migrate generate gives. On a runtime whose commands module exports no migrations, it migrates nothing.

Terminal window
plumix dev
plumix dev --port 3000

Builds the deployable artefact. On Cloudflare that is the Worker bundle; on Node and Bun it is dist/client for the browser and dist/server to run, built client-first because the server bundle bakes in the client’s asset manifest. Every table owner’s history is copied beside the server, as dist/server/migrations/<owner>/, and the plugins’ and the site’s data migrations are bundled on their own into dist/server/migrations/data-migrations.js, never into the server bundle; a site that declares none gets the file with an empty list. The built server applies the pending histories, then the pending data migrations, when it starts, before it listens. A failed apply exits it 1 without listening. On Cloudflare the Worker carries no data migration, and build writes no data bundle.

Terminal window
plumix build

Cloudflare only. Builds the Worker with CLOUDFLARE_ENV set from --env, runs the check that the site’s own tables are not ahead of its history, prints the plan, and applies every owner’s pending migrations and then every pending data migration to that environment’s production D1 database without asking, as plumix migrate --production --yes (or --env <name> --yes) does. Then it runs wrangler deploy, with --env <name> when one was given and every other argument appended unchanged. Without --env it takes CLOUDFLARE_ENV as wrangler does, and an --env that disagrees with an already-set CLOUDFLARE_ENV fails with deploy_env_conflict before anything builds. A failed build, check or apply exits non-zero, and wrangler deploy never runs.

Terminal window
plumix deploy
plumix deploy --env qa
plumix deploy --dry-run

Cloudflare only. Does what deploy does against the environment’s Worker Previews database, the previews.d1_databases that plumix migrate --preview reaches, then runs wrangler preview with --env <name> when one was given and every other argument, such as --name, appended unchanged.

Terminal window
plumix preview
plumix preview --env qa --name feature-x

Cloudflare only. Forwards to wrangler types, for the ambient binding types wrangler.jsonc describes.

Terminal window
plumix types

Applies every table owner’s pending migrations. The owners run in order: core first, then each package a plugin’s schemaModule resolves into, in the order the plugins appear in plugins, then the site itself when a plugin’s schemaModule lands inside the site’s own package (the one holding plumix.config.ts, or the file --config names) and the site has a migrations/ folder at its root. Each package ships its own history, and each owner records what it applied in a table of its own, __drizzle_migrations_<owner>: core, site, or the package name lowercased with each run of other characters replaced by _ (@plumix/plugin-comments records in __drizzle_migrations_plumix_plugin_comments). It prints a line per owner, such as core: applied 2 migrations or core: up to date, and under it each migration it applied, with its date and a short hash.

After every owner’s migrations it runs the data migrations: core’s, then each plugin’s dataMigrations module in the order of plugins, then the site’s, from the module a plugin inside the site’s package names and the config’s own dataMigrations, a path relative to plumix.config.ts. The CLI imports them in its own process. Each is recorded under its owner and name in __plumix_data_migrations once its up resolves, and is skipped from then on. Each owner that declares any prints <owner>: applied 1 data migration, with each name under it, or <owner>: data migrations up to date. A duplicate name within one owner fails with migrate_data_duplicate_name before any data migration runs. An up that throws stops the run with migrate_data_failed, naming the owner, the data migration and the cause; it is not recorded, nothing after it runs, and the next run retries it. A module that does not resolve fails with migrate_data_module_unresolved before the database opens.

Core’s first data migration renames the subscriber role to member on users and pending invites. contributor is no longer a default role, so before it writes anything plumix migrate, like plumix dev and plumix deploy, checks the users table: when any user holds contributor and plumix.config.ts declares no contributor role, it fails with migrate_contributor_role_missing and prints a contributor role to paste into roles, granting the core rights contributors held on this site. A plugin’s own grant to contributors is not in it; a site using the media plugin adds entry:media:delete to keep contributors deleting their own uploads. With the role declared, contributors keep their stored role, and a site with no contributors migrates without it.

Every run first prints the database it opened, such as → local · DB · site. Without a target option it applies to the local database. The options that name a deployed database are the runtime’s: Cloudflare declares --production, --preview and --env <name>, which the Cloudflare guide covers, and reaches the D1 database the wrangler config names through wrangler’s remote bindings, so it needs wrangler login or an API token, as wrangler does. Node and Bun declare none, so a target option fails there with unknown_option, naming the runtime. On Cloudflare the local database is the D1 copy plumix dev uses, under .wrangler/state/v3. When the wrangler config declares more than one D1 binding, --binding <name> picks one, and the error without it names the bindings it found.

Before it writes to a deployed database, plumix migrate prints a plan: the target line, any note about it, and each owner’s pending migrations and pending data migrations. It then asks for confirmation. --yes skips the question, --dry-run prints the plan and stops, and without a terminal to ask in and no --yes it fails with migrate_unconfirmed, having written nothing. A local run never asks. A script that ran plumix migrate --remote runs plumix migrate --production --yes.

plumix migrate fresh rebuilds a database from nothing. It drops every table in it, each owner’s __drizzle_migrations_<owner> table, plugin tables and tables no migration created included, so only the engine’s own tables are left (sqlite_sequence, and D1’s _cf_*). Foreign-key checks are suspended for the drop. It then runs the normal migrate against the same database: every owner’s schema history in order, then the data migrations. It ends with the next step, loading data with plumix import or plumix sync push. It deletes no files, so uploads stay where they are.

Without a target option it rebuilds the local database. On Cloudflare, --preview and --env <name> rebuild that deployed database. --production, and --env production, fail with migrate_fresh_production before anything connects, and no option overrides that: to start a production database over, delete and recreate it on the platform. Before it drops anything it prints a plan: the target line, the tables it drops, and every migration it applies afterwards. A local run then asks [y/N]. A deployed one asks you to type the database’s name, and fails with migrate_fresh_name_mismatch on anything else. --yes skips the question, --dry-run prints the plan and stops, and without a terminal to ask in and no --yes it fails with migrate_fresh_unconfirmed, having dropped nothing. Before a deployed wipe it prints a restore point: on Cloudflare, the D1 Time Travel bookmark for the database as it is now, and the wrangler d1 time-travel restore <database> --bookmark=<bookmark> command that puts it back.

plumix migrate status lists each owner in order with its applied and pending migrations, each with its date and a short hash, then each owner that declares data migrations with its applied and pending ones by name.

plumix migrate, plumix migrate fresh and plumix deploy never write a migration. When the site owns tables, each first checks its history: it runs drizzle-kit generate into a temporary copy of the site’s migrations/, and if that writes anything, the site’s tables are ahead of their history. The command then fails with migrate_site_drifted before it drops, opens or deploys anything, naming the files a generate would write; run plumix migrate generate, or plumix dev, and commit what it writes. A site that owns no tables never drifts.

plumix migrate generate writes the next migration of the site’s own tables, the ones declared by a plugin whose schemaModule resolves inside the site’s package. It writes .plumix/site-schema.ts, re-exporting only those schema modules, then runs drizzle-kit generate --dialect sqlite --casing snake_case --schema .plumix/site-schema.ts --out migrations, and the SQL lands in the site’s migrations/. Commit that folder. Core’s tables and every package’s tables are never part of it, because they ship in their own histories, though a site table may reference one of them by foreign key. When nothing has changed it writes nothing, and when the site owns no tables it says so and writes nothing. When drizzle-kit needs to know whether a column or table was renamed, it asks, and without a terminal to ask in the command fails: run plumix migrate generate in an interactive terminal to answer it.

Terminal window
plumix migrate
plumix migrate --production --dry-run
plumix migrate --env qa --preview --yes
plumix migrate fresh
plumix migrate fresh --env qa --dry-run
plumix migrate status
plumix migrate generate

The first plumix migrate against a database set up by an earlier release adopts it. Such a database has the old tracking table, d1_migrations on Cloudflare or __drizzle_migrations on Node and Bun, and no per-owner table yet. The command applies every owner’s history to a scratch in-memory database and compares the two schemas: every table, index and trigger, and each table’s columns. When they match, it records every owner’s migrations as applied without running any DDL, and prints that the site’s drizzle/ folder is no longer read. When they differ, it changes nothing and fails, listing each difference. When a difference is a table the site’s own schema declares and its migrations/ has no migration for, the failure says to run plumix migrate generate first.

Replaces the local or the deployed site’s data with the other’s. plumix sync pull copies the deployed site into the local database; plumix sync push copies the local database to the deployed site. Each named scope is replaced whole, never merged, with every row under its own id. Moving data between environments covers the scopes, the dump, how references survive and what runs afterwards.

--url <origin> names the deployed site and is required. On a runtime whose remote end calls the site, --token is its API token, else PLUMIX_API_TOKEN, else one the device flow mints on a terminal. --scope names a scope, once per scope: content (the default), media, users (only with content) or plugin:<id>. --dry-run prints, per table, what the sync would replace, add and remove, then stops. The source’s URLs inside JSON content are rewritten to the target’s origin and base path; --no-rewrite-urls turns that off. A push asks for confirmation unless --yes is passed, and fails with sync_push_unconfirmed when there is no terminal to ask in. Both ends must have applied the same migrations; otherwise the sync fails with sync_migrations_differ before it exports anything. On a runtime whose commands module exports no sync, it fails with sync_runtime_unsupported. A runtime’s sync may declare target options that name which deployed database the remote end reaches; sync and import accept them as migrate does, and fail with unknown_option on any other. On Cloudflare they are --production, --preview and --env <name>, and the remote end needs one. A pull that failed goes on from the dump it left, exporting only what it had not. A site behind Cloudflare Access also needs CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET, set to an Access service token’s client ID and secret: with both set, every request the command sends the site carries them. A request Access redirects to its sign-in page fails with remote_behind_access.

Terminal window
plumix sync pull --url https://example.com --production --dry-run
plumix sync pull --url https://example.com --production --scope content --scope media
plumix sync push --url https://example.com --production --yes

Replaces the local or the deployed site’s content with a file’s. plumix import <dir> loads a sync dump; plumix import <file.jsonl> loads authoring records, one JSON object per line, checked against the site’s entry types, taxonomies and fields before anything is opened. A failing record fails the import with import_records_invalid, naming each record by file and line, and nothing is written. Importing content covers the authoring format.

Without --url the import loads the local database. --url <origin> loads the deployed site instead, and then takes a snapshot first and asks for confirmation unless --yes is passed, failing with import_unconfirmed when there is no terminal to ask in. --dry-run prints what the import would replace, add and remove, then stops. A dump’s URLs are rewritten to the target’s origin and base path as sync rewrites them; --no-rewrite-urls turns that off. With --url, the import takes --token and the Cloudflare Access variables as sync does.

Terminal window
plumix import content.jsonl --dry-run
plumix import content.jsonl --url https://example.com --yes

Lists the site’s scheduled tasks, or fires one schedule now — the externally-driven half of cron, for a cron: false deploy that hands firing to a system cron or a Kubernetes CronJob instead of a runtime’s own trigger. Node.js covers the deploy shape this exists for.

plumix cron list prints every schedule the site’s plugins declare, and which tasks run on each. A task that declares no cron of its own runs on every firing, so it is printed under all of them. plumix cron run "<expression>" fires the tasks due on that schedule, refusing an expression nothing declares rather than exiting green having done nothing.

Terminal window
plumix cron list
plumix cron run "*/5 * * * *"

A run takes the same claim-and-lease guard the in-process scheduler does, so it is never overlapped by another run of the same schedule — across replicas, when the database is shared — and it says which it did rather than failing silently: Skipped "…": another run holds the lease. It exits non-zero when a task failed, naming it; a skipped run exits zero, because the guard working is not a failure. Put concurrencyPolicy: Forbid on the CronJob or flock around the crontab entry anyway — those stop a second process before it opens a database at all, which the lease alone cannot do.

Reports stored field values that are not in the form their field’s type declares, or converts them. Such values come from changing a field’s type over existing rows, from an import, or from a direct write. Changing a field’s type covers where they come from. plumix meta (or plumix meta report) lists them per store, type and field without writing anything. plumix meta settle converts every value with a settled form and leaves the rest, the values no field type accepts, as they are.

Terminal window
plumix meta
plumix meta settle

It runs through the site’s own handler, so the conversion uses the site’s database, plugins and CDN purges, and a settle that changes values purges the pages that show them, as a request would. It walks every row in one run, however many there are.

It reaches the database migrate reaches, with the same options: the local one without a target option, and on Cloudflare the deployed D1 that --production, --preview or --env <name> names, with --binding <name> when the wrangler config declares several. On Cloudflare it also binds the R2 bucket and KV namespace the site’s storage and kv slots name. Node and Bun declare no target options, so one fails there with unknown_option, naming the runtime. Every run first prints the database it opened.

Before a settle writes to a deployed database, it prints what it would settle and asks for confirmation, as migrate does. --yes skips the question, --dry-run prints the plan and stops, and without a terminal to ask in and no --yes it fails with meta_settle_unconfirmed, having written nothing. A local settle never asks.

Terminal window
plumix meta --production
plumix meta settle --production --dry-run

A CDN purge reads its credentials from the environment the command runs in, so a deployed settle purges only when they are set there. On Cloudflare, Workers Caching (cache.enabled) is reachable only from inside the Worker, so pages it holds keep their copy until it expires.

plumix mail test <to> sends core’s mailTest mail to <to> through the site’s real transport, so you can check a mailer works before a sign-in link depends on it. Testing the mailer is an operator task, so it is a command and not an admin screen (ADR 0034). mailTest is declared like any mail, so it shows in the preview gallery and a site can override it by name.

Terminal window
plumix mail test ann@example.com

Locally it runs through the site’s own handler against the env the runtime opens, the same way meta does, so the mailer reads the secrets in .env, and on Cloudflare .dev.vars too. A send prints the recipient and exits 0. A transport that refuses the mail fails with mail_send_failed and prints the transport’s own error, such as SMTP 535 auth failed, and exits 1. A site with no mailer fails with mail_not_configured. A <to> that is not an email address fails with mail_invalid_arguments.

--remote --url <origin> sends the mail through the deployed site instead, with its production secrets, by calling POST /_plumix/api/v1/system/mail/test there. The route needs the site’s REST API enabled and an API token whose user holds settings:manage. A scoped token also needs that scope. The output and exit codes are the same as a local run.

The token comes from --token, else the PLUMIX_API_TOKEN environment variable. In CI, set the variable:

Terminal window
PLUMIX_API_TOKEN=pl_pat_… plumix mail test ann@example.com --remote --url https://example.com

With neither, on a terminal, the command runs the site’s device flow: it prints a link and a code, you approve the code in the admin, and it carries on with the token the site mints. It never stores that token. Without a terminal and with no token, it fails with mail_remote_token_missing.

A site behind Cloudflare Access also needs CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET, set to an Access service token’s client ID and secret: with both set, every request the command sends the site carries them. A request Access redirects to its sign-in page fails with remote_behind_access.

Core ships no rate limiter for the route, as for the rest of the REST API. To throttle it, apply the same edge rule Rate limiting describes.

Prints a diagnostic snapshot: the resolved config path, the runtime and database kind, every installed plugin, the schema table count, and the Node and platform versions the command itself is running on.

Terminal window
plumix doctor

Under deploy: it then prints what the runtime finds in the project’s deploy config, one finding per line. ok means the setting is right as it stands. warn means a deploy would run without something, and the line under it gives the fix. On Cloudflare, doctor warns when wrangler.jsonc leaves Workers Logs off, then lists every D1 database a target can reach: the top level’s, the top-level previews, and each environment’s own and its previews, noting when they are inherited from the top level. It shows a preview_database_id labelled “wrangler dev only”, since no Plumix command targets it, and warns about an environment or a previews block that binds no D1 database. On Node and Bun it reports where the server’s logs go.

deploy:
warn Workers Logs is off in wrangler.jsonc, so the deployed Worker keeps no logs
fix: Add to wrangler.jsonc: "observability": { "enabled": true }

Extracts translatable strings to .po catalogs and compiles them to the runtime format Lingui reads, via @lingui/cli. Four subcommands: extract, compile, init (scaffolds a lingui.config.ts built with defineLinguiConfig from plumix/lingui, and i18n:extract, i18n:compile, i18n:check and prepack scripts that run these verbs, skipping anything that already exists), and verify (extracts to a snapshot, diffs the active message set, and restores — the CI gate behind extract --check).

Terminal window
plumix i18n extract
plumix i18n compile
plumix i18n init

compile writes an ES module locales/<locale>.mjs per catalog and exits non-zero when a catalog has a message Lingui can’t parse, such as an unclosed {. A missing translation doesn’t fail it: the message falls back to its source string. --dts also writes a <locale>.d.mts declaration beside each .mjs, for a package that imports a catalog by name through its exports map. Any other argument goes to lingui compile.

Unlike every other command, i18n runs without loading plumix.config.ts — it is tooling for whatever package you run it from, not a step in a site’s build.

Recognised ahead of the command name; anything after it passes through to the command unparsed, so a command’s own flag, like migrate’s --binding, is never mistaken for one of these.

Path to plumix.config.{ts,js,mjs}, for a project whose config does not live at the project root under the default name.

Terminal window
plumix build --config config/plumix.config.ts

The project root, read as the directory holding plumix.config.ts unless --config says otherwise. Defaults to the process’s working directory. Every path a command resolves — the schema output, the migrations directory, a Node SQLite file — is relative to this, so it has to land before a command that opens a database does any of its own work.

Terminal window
plumix doctor --cwd ../my-site

Prints diagnostic output — the detail behind a command’s own decisions, such as why --help fell back to the built-in commands alone.

Terminal window
plumix build --verbose

Prints command groups and their one-line descriptions: the built-ins always, and a runtime’s own commands once its config loads. Also -h, and the default when plumix runs with no command at all. After a command name, it prints that command’s help instead, as in the quickstart.

Terminal window
plumix --help

Prints the installed plumix package version. Also -v.

Terminal window
plumix --version

Runtime Adapters documents commandsModule, the seam a runtime uses to contribute dev, build, deploy and types, to open its database for migrate, and to move data for sync. Cloudflare Workers and Node.js show what each runtime’s commands look like from the operator’s side, cron included.

Run plumix doctor in a project to see the reference above against a real config — it is the fastest way to confirm which runtime’s commands you should expect from --help.