directus-deploy-cli
v0.29.8
Published
Per-entity, non-atomic Directus deployment tool. Reconciles collections/fields/relations/roles/policies/permissions/flows/operations/migrations/seeds from git to any environment.
Maintainers
Readme
directus-deploy-cli
Per-entity, non-atomic Directus deployment tool. Reconciles code + config from a git repository to any Directus environment. Handles collections, fields, relations, roles, policies, permissions, flows, operations, raw-SQL migrations, register manifests, and seed data.
Why not the built-in schema apply?
Directus's /schema/apply is atomic — one edge case ("field already exists", "relationship already associated", dangling meta.group ref, adopted-but-unregistered raw-SQL column) blocks every other change. In a repo with many contributors, multiple environments, and raw-SQL adopted tables, that atomic model becomes a firefight generator.
directus-deploy iterates each entity independently: GET → POST | PATCH | SKIP. If one entity fails, the other 900 still apply. Per-entity report tells you exactly what changed and why the skips/failures happened.
Install
npm install -g directus-deploy-cliUse
DIRECTUS_URL=https://your-directus DIRECTUS_TOKEN=... \
directus-deploy verify \
--snapshot-dir=./directus_config/snapshot \
--config-dir=./directus_config/collections \
--seed-dir=./directus_config/seed \
--migrations-dir=./migrations \
--register-dir=./migrations/registerCommands: plan (dry-run report), apply (reconcile), verify (drift check; exits non-zero if apply would change anything).
Overview
One matrix over every target: each environment compared against the git ref it deploys from, plus a promotion-queue column showing what sits on develop but hasn't reached master yet.
$ directus-deploy overview
test staging prod develop → master
vs origin/develop vs origin/develop vs origin/master promotion queue
migrations ✓ 86 applied ✓ 86 applied ✓ 85 applied 1 new
extensions ✓ 21/21 match ✗ 1 behind ✗ 1 behind 3 changed
config ✓ in sync ✗ 10 changes ✓ in sync 20 file(s)
seeds ✓ in sync ✗ 31 changes ✓ in sync 3 file(s)Each target declares its branch in the targets file ("ref": "origin/develop"); the artifacts at that ref are materialized via git archive and dry-run against the env, so the comparison is branch-vs-env, not worktree-vs-env. A target without ref falls back to the working tree. The promotion pair is inferred from the refs (the build_forbidden target pins the destination), widening to the full targets file when only a subset is probed and defaulting to origin/develop → origin/master as a last resort; --from/--to override. It is pure git and never affects the exit code.
Below the matrix, the promotion block is a release preview: the queued commit list (merge subjects carry PR numbers), and per queued extension the expected artifact commit joined with what the destination currently runs (prod runs 5cb20e99 → would get 59ce4ffc) plus the commits in between — the same view a release PR body would show. The join reuses the /_meta sourceCommit already fetched for the extensions row, so it costs no extra network.
Exit codes: 2 a check could not run, 1 drift, 0 in sync — errors outrank drift, because a run with an incomplete check has an incomplete drift picture. --json emits the full report (untruncated detail lists) for dashboards or Slack bots.
Progress and deadlines
Checks announce themselves on stderr — once when they start, once when they finish — so a long run is legible while it runs and stdout stays clean for --json and for piping:
$ directus-deploy overview --targets staging,prod
staging materialize ... origin/develop
staging config ...
staging probe:attributes ...
staging migrations ok 0.9s 88 applied, 0 pending, 0 mutated
staging probe:attributes ok 2.3s in sync
staging config ok 86.0s 0 config, 0 seed changes
prod config TIMEOUT 30.0s prod config timed out after 30000msBoth edges are printed on purpose: the config leg alone runs 70–90s, so waiting for completion before saying anything would reproduce the silence this is meant to fix, just on a shorter clock.
--progress auto|plain|none controls it (auto = plain; none restores silent operation for anything parsing stderr).
--timeout <ms> (default 300000, 0 disables) bounds every check — git, HTTP reconcilers, and drift probes alike. A check that trips it renders as TIMEOUT in the matrix and sets exit 2 rather than stalling the command. A timed-out probe is killed by process group, not by pid: probes run as sh -c "<cmd>", and sh only exec-replaces itself for a single command, so a | b or cd x && a would otherwise leave the real worker orphaned and still holding its connection to the target.
The default is deliberately generous because the config leg legitimately runs 70–90s per target while every other leg finishes inside 4s. The tighter bound lives in the HTTP client, which times out each request after 30s and caps a whole retry sequence at 120s — without that ceiling, a target that 503-flaps costs six retries of exponential backoff per request, which is what made overview look deadlocked with no output at all.
Timeouts never replay a write. A 503 means the server shed the request without processing it, so any method is safe to retry. A timeout is an abort on the client side — the request may have reached the server and committed — so it is replayed only for GET/HEAD. A timed-out POST/PATCH/DELETE surfaces immediately rather than risking a duplicate row.
Custom drift probes
Repos have drift surfaces the CLI can't know about (externally-managed data pipelines, computed caches). Declare them in the targets file and overview grows a row per probe:
"drift_probes": [
{ "name": "attributes", "cmd": "node scripts/attributes-drift.mjs --url {url} --token-env {token_env} --json" }
]The command runs once per target from the working tree ({url}, {token_env}, {target} substituted) and must print one JSON line: { "clean": true|false, "summary": "…" }. Probe drift counts as drift; probe failures as errors.
Seeds
Data tables managed as code: directus_config/seed/<collection>.json files in the Tractr shape ({collection, meta, data}), applied row-by-row keyed on the collection's real primary key. The reconciler compares only the columns present in the seed rows — runtime columns stay untouched — and fetches exactly those columns (a pgvector column won't blow up your instance).
directus-deploy seeds pull categories \
--fields id,parent_id,code,title,status --insert-order 50 # bootstrap/refresh from a live env
directus-deploy apply --entities seeds # converge an env
directus-deploy apply --entities seeds --prune # also DELETE server rows absent from
# seeds with meta.delete=trueseeds pull preserves an existing file's meta, derives the field list from the file on re-pulls, orders parents before children (self-referencing FKs survive fresh-env inserts), and hard-errors on duplicate PKs. Server rows absent from a seed are reported: aggregated and informational for meta.delete: false collections, per-row EXTRA (verify fails) for meta.delete: true — apply --prune deletes those, children-first.
Extension deploy model
One rule: new builds enter only at test; staging and prod replay the archive.
extensions push <n> --target test— builds and deploys. With--publish(implied by--via control) the tarball is ALSO archived togs://<artifact_bucket>/<n>/<sha>.tgz(immutable, first-write-wins) — that archive is what makes the build promotable.extensions promote <n> --target staging|prod— installs the archived artifact for the sha of the current checkout. Never builds; refuses if the artifact is missing. This is the only path to prod (build_forbidden).- Transports:
--via ssh(default; needs ssh/rsync + port-22 egress) or--via control(HTTPS-only, through the target'scontrol_urlfunction — for sandboxes/agents).
So a test deploy is two uploads of one tarball: bucket (archive, for later promotes) and target (install). Deploys above test are one download (bucket) + one install.
VM control
For environments whose VM sleeps when idle: directus-deploy vm status|start|stop --target <name>. start is an instant no-op when <base_url>/server/health already answers, otherwise dispatches the wake and polls until healthy. Backed by a tiny token-gated cloud function (one per instance — see cloudfunctions/vm-control/ for the 5-minute deploy); the target declares control_url in the targets file and the token comes from DIRECTUS_<TARGET>_CONTROL_TOKEN. Callers never hold GCP credentials.
MCP server
Ships an MCP server exposing the reconciler as structured tools:
directus_plan/directus_apply/directus_verify— the write path (plan is a dry-run).directus_overview— read-only deployment matrix (targets vs their refs + promotion queue); the best first call for an agent assessing deployment state.directus_migrations_status,directus_extensions_status,directus_extensions_diff— read-only per-dimension checks.
The server auto-loads .env from its cwd, so pointing cwd at the backend repo gives it the DIRECTUS_<TARGET>_TOKEN vars. Register in your Claude client's .mcp.json:
{
"mcpServers": {
"directus-deploy": {
"command": "node",
"args": ["<abs-path>/node_modules/directus-deploy-cli/dist/mcp-server.js"],
"env": { "DIRECTUS_URL": "...", "DIRECTUS_TOKEN": "..." }
}
}
}On-disk layout
Compatible with the directus-sync-style tree:
directus_config/
snapshot/{collections,fields,relations}/*.json
collections/{roles,policies,permissions,flows,operations}.json
seed/*.json
migrations/*.sql
migrations/register/*.jsonRegister manifests: a
collection_meta.note(oricon) containing a;requires a sql-runner token (SQL_RUNNER_<TARGET>_TOKEN, orSQL_RUNNER_TOKEN) so the adopt INSERT runs unsplit — the raw-query fallback splits on;and would abort adoption. Apostrophes are always safe (escaped). Without a token, a;-bearing note fails at author time with an actionable message.
License
MIT
