@elytracms/cli
v0.0.101
Published
The Elytra CLI — run a consuming repo's studio, frontend, and Convex backend under one command.
Readme
@elytracms/cli
The Elytra CLI — run an Elytra consuming repo's studio, delivery frontend, and Convex backend under one command.
Installed as a devDependency in projects scaffolded by
create-elytra; its dev/build
scripts call elytra.
Commands
elytra dev Start the studio + frontend (and Convex in connected mode)
together as one supervised process group. One Ctrl-C stops
everything; if any process exits, the rest are torn down.
elytra build Build the studio + frontend (and deploy Convex in connected
mode), in order — failing as a whole if any step fails.
Optional target (the per-app Vercel build commands):
elytra build studio Convex deploy coupled with the studio
build (functions + client in lockstep)
elytra build frontend frontend build only
elytra init First-run setup: provision Convex, generate the auth signing
keys, verify readiness. Idempotent — safe to re-run.
elytra doctor Diagnose the active deployment's readiness and print each fix.
Includes the expected deployment env vars, reported set/missing
by NAME only — values are never read into the report or printed.
When the publish revalidate pair is set, also sends a signed
no-op ping to the endpoint to prove the publish → live chain
end to end. Also checks your cms/ route set for URL conflicts
(see below), and reports the generated-previews artifacts
(manifest present, image files present, no strays — warnings
only; previews are optional). Read-only; exits non-zero if any
check fails.
elytra previews Render every canvas-eligible component's fixtures against your
theme and write committed insert-menu preview artifacts:
640×400 WebP images in studio/public/elytra-previews/ plus an
importable studio/elytra-previews.json manifest (per-entry
input hashes — re-running with unchanged inputs rewrites
nothing; removed components' images are pruned). Wire it with
`generatedPreviewsFromManifest` from
`@elytracms/studio/customization` (two lines — see the docs).
Rendering needs Playwright once (optional, on demand), at
the workspace root:
pnpm add -Dw playwright && pnpm exec playwright install chromium
Options:
--check verify the input hashes without rendering (no
Playwright needed); exits non-zero when previews
are missing/stale/orphaned. Wire into CI/builds.
--port <n> harness dev-server port (default: first free 5440+)
elytra help Show help
elytra version Show versionRun from anywhere inside an Elytra project (a directory with elytra.config.ts).
Every subcommand accepts --help/-h and prints its own usage — inertly: no
project inspection, no process spawned, no network call, no write. elytra dev
--help never starts a server; elytra doctor --help never runs the network
diagnostic; elytra build --help never validates a target.
Project layout
The studio app, the frontend app and the theme are found at studio/,
frontend/ and components/theme.css — the scaffold layout — unless the
repo-root package.json (beside elytra.config.ts) declares otherwise:
{ "elytra": { "studio": "./apps/editor", "frontend": "./apps/site", "theme": "./styles/site.css" } }Every key is optional. An invalid section or a declared path that does not
exist is reported (elytra doctor fails project-layout; the other commands
refuse to start) — never silently replaced by the default. Full rules:
docs/guide/06-cli-reference.md.
Mode-aware
The CLI reads the studio app's env files — the ones Vite loads for it
(.env, .env.local, .env.<mode>, .env.<mode>.local) — through the SAME
backend-mode parser the studio runs, so the two never disagree:
- Playground (
VITE_ELYTRA_MODE=playground) — runs the studio + frontend only; no Convex. - Connected (
VITE_CONVEX_URLset) — also runsconvex dev(or deploys on build) once a deployment is provisioned (CONVEX_DEPLOYMENTpresent). - Any other
VITE_ELYTRA_MODEvalue is the studio's boot error, and the CLI names it the same way.
It orchestrates and owns the process lifecycle; it does not reimplement Vite, Next, or Convex — each step shells out to the underlying tool and inherits its watch/HMR/build behaviour.
Dev ports
The studio defaults to :5180, the frontend to :3000. One lever moves both
together — set ELYTRA_DEV_PORT_OFFSET in studio/.env (e.g. 100 → studio
:5280, frontend :3100) to run a second Elytra project alongside this one
without a clash. elytra dev reads it from studio/.env and forwards it to
both child processes; the studio's own vite.config.ts also reads it directly
(so vite dev run standalone honors it too). Unset (the default) leaves both
ports exactly as they are today.
Deploying (Vercel)
Deploys are git-push-driven — there is no elytra deploy. A consuming repo
deploys as two Vercel projects on the same git repo, plus the Convex
deployment that the studio build pushes functions to:
| Vercel project | Root Directory | Build command |
| -------------- | -------------- | --------------------------------- |
| studio | studio | pnpm exec elytra build studio |
| frontend | frontend | pnpm exec elytra build frontend |
elytra build studio wraps Convex's recommended Vercel integration: it runs
convex deploy --cmd 'pnpm run build', which pushes your Convex functions and
then runs the studio build with the deployment URL injected into its env
(VITE_CONVEX_URL). The built studio always matches the functions it was
deployed with, and there is no hand-set URL to drift out of sync with the
deploy key's target. elytra build frontend runs the Next.js build only — the
frontend reads its Convex URL at runtime, not at build time.
Which env lives on which plane
Env lives on three planes. Keep them apart — most misconfiguration is a value set on the wrong plane:
- Local
.envfiles (studio/.env,frontend/.env) — local development only. Gitignored; they never reach Vercel or the Convex deployment. - Vercel project env — build- and runtime-env per Vercel project:
- Studio project:
CONVEX_DEPLOY_KEY(a production deploy key generated in the Convex dashboard — it selects and authorizes the deployment). Do not hand-setVITE_CONVEX_URL; the build injects it (above). Optional: the AI provider key (ANTHROPIC_API_KEY, or the Vercel AI Gateway) for the studio's AI route — a host/server env, neverVITE_-prefixed. - Frontend project:
ELYTRA_CONVEX_URL,ELYTRA_PROJECT_ID,REVALIDATE_SECRET,DRAFT_PREVIEW_TOKEN— see the scaffold's CONNECT.md for what each pairs with.
- Studio project:
- Convex deployment env — set with
npx convex env set(fromstudio/); lives on the deployment itself, visible in the Convex dashboard but in no file: the auth signing keys (JWT_PRIVATE_KEY,JWKS— set vianpx @convex-dev/auth),SITE_URL, the instant-publish pairCANVAS_REVALIDATE_ENDPOINT/CANVAS_REVALIDATE_SECRET, andDRAFT_PREVIEW_TOKEN(the secret and the token must match their frontend-plane counterparts).elytra doctordiagnoses this plane's readiness.
Because plane 3 lives in no file, it is the easiest to overlook — elytra
doctor therefore enumerates the expected vars there (auth keys, SITE_URL,
the instant-publish pair, the draft-preview token, plus the AI provider key
on the studio-host plane when the AI route is present) and reports each one
set or missing, by name only: the doctor parses names out of npx convex
env list and discards every value on the spot — values are never stored,
printed, or logged. A missing required var (the auth keys) fails the run
with its exact fix line; a missing recommended var (SITE_URL, the AI key,
the draft-preview token) is a named warning that doesn't change the exit
code. The studio's boot-time "Deployment setup" screen lists the same
expected vars, so the CLI and the studio tell one story.
The instant-publish pair — CANVAS_REVALIDATE_ENDPOINT and
CANVAS_REVALIDATE_SECRET — gets special treatment, because the publish path
silently no-ops while either is unset: the write-chokepoint trigger
schedules the revalidate webhook, and the action delivers it, only while both
are set. Publishes then never update the live site until a redeploy, with no
error anywhere. Both missing is a warning carrying exactly that consequence (a
project without a delivery frontend may legitimately leave them unset); one
of the two set fails the run, because a half-configured pair has no
legitimate reading (documented together in the scaffold's CONNECT.md, set in
one shell block, so a partial state is far more likely an interrupted setup
than a deliberate choice). When the pair IS set, the doctor goes one step
further and proves the chain end to end: it POSTs a signed no-op ping (the
probe event — the one event that derives no cache tags) to the endpoint. A
200 {"revalidated":[]} passes (endpoint reachable, the frontend's
REVALIDATE_SECRET matches, and the frontend speaks this engine's event
wire); a 401 fails as a secret mismatch between Convex and the frontend host;
a 400 fails as a version skew (the frontend runs a different Elytra version);
a network error only warns (unreachable from your machine is not the same as
broken — Convex may still reach it). The pair's values are read in-process
for the ping only and never printed. (There is no third, scope var any more:
the webhook carries the event, and the frontend derives its cache tags with
its own ELYTRA_PROJECT_ID.)
The draft-preview token — DRAFT_PREVIEW_TOKEN — gates the deployment's
draftSnapshot query and must equal the frontend host's own
DRAFT_PREVIEW_TOKEN (the same secret that gates the frontend's
preview-enable route); the doctor can only see the Convex-deployment side; it
has no pairWith escalation because its real partner lives on a plane this
doctor cannot read. Missing it is a warning: draft preview is closed
everywhere (401 on both sides) until it's set — a project that never uses
draft preview may legitimately leave it unset.
Route conflicts (elytra doctor)
Routes are code, so a route conflict is a build-time bug, and elytra doctor
reports it: two routes that would match the exact same URLs fail the run with
the offending ids and patterns named. Nothing about this needs a deployment —
the check runs in playground mode too.
When the studio's deps are installed, the doctor executes elytra.config.ts
through the studio's own Vite and judges the routes it resolved — exactly what
the studio and delivery serve. Without them it falls back to reading your
project's cms/index.ts natively (the collections + routes the scaffold's
elytra.config.ts passes through), and the route-conflicts row says which
of the two it judged. That tree is plain, React-free TypeScript by convention,
so the CLI imports it natively — no bundler, no Vite, no dev server. Two
consequences of the fallback worth knowing:
- It needs a Node that runs TypeScript natively (≥22.18). On an older Node the check reports "not checked here" and names the version — never a false pass.
- If the route set cannot be read for any reason (no
cms/index.ts, a module that throws, an import the CLI cannot resolve such as atsconfigpath alias, TypeScript syntax Node cannot strip, or aroutesexport of the wrong shape), the check reports why, as "not checked here". It never crashes the doctor and never claims your routes are fine.
Conflicting hierarchy mounts
A hierarchy mount (/:path*, /docs/:path*) is not a router route: it is pulled
out of the router and resolved per request against a collection's composed page
tree. So the route-conflict check above cannot see mounts at all, and a second
row — mount-conflicts — judges them by what they declare instead:
- Two collections mounted at the same path fail the run. They claim an identical URL space, and the only thing separating them is declaration order.
- A mount nested inside another's URL space is a warning. It is a real shadow, but only where the outer collection's tree actually composes a path into the inner mount's prefix — that is content, which a CLI cannot see, so naming the precedence is honest and failing your build on it would not be.
The precedence being named is worth stating plainly, because nothing else does: mounts are tried in declaration order, not by specificity, and the first whose collection resolves a document for that URL wins. A root mount declared first therefore outranks a deeper one — the opposite of how ordinary routes behave, where more static segments always win. Declare the more specific mount first.
Declarations that serve nothing
A third row — unreachable-routes — asks a different question from the two
above: not "do two declarations fight?" but "can this declaration ever serve
anything at all?" Three shapes trip it, all of which used to 404 silently with
no issue raised anywhere:
- An all-dynamic route over a mount's URL space — a warning.
/:slugmatches every one-segment URL, so the/:path*mount is consulted there only where the route resolves no document. Nothing is unconditionally unreachable (the resolver falls through — see Precedence below), but precedence at that depth is then decided by your content: a page whose composed path collides with a post slug is silently shadowed, and neither side is told./blog/:slugis fine and always was: a static segment claims only the namespace you carved out. It is the all-dynamic pattern that takes a whole depth. - A mount on a collection that cannot back it — no
parentself-relation, so there are no nested paths to compose, or the collection is not declared at all. The resolver skips such a mount silently; every URL under it 404s. This one fails the run: no content and no ordering can make it work. - A catch-all with a dynamic prefix (
/:lang/:path*) — also a failure. It is dead in both directions: every router drops it as a catch-all, and no mount is built from it because a mount prefix must be static. For per-locale mounts, declare one mount per locale instead:{ pattern: '/de/:path*', locale: 'de' }.
Precedence: a match is not a result
A route always wins over a mount for the URLs it can actually serve. When a
route matches a URL but resolves no document there — no post with that slug, or
none published in this perspective — the resolver falls through to the hierarchy
mounts instead of returning 404. So declaring /blog/:slug does not take the
/blog/* namespace away from your page tree: a page at blog/kid is served,
and it stops being served the moment a post with slug kid exists.
The consequence worth knowing: which collection serves such a URL depends on what exists, not on the routes alone, and it is evaluated per perspective — a draft-only post wins in preview while the published page still serves live.
Redirect loops are not checked here. Since EC-303 redirects are editorial
data in the backend, not code — a CLI has no editorial data. The studio checks
them (and route conflicts, and the route-side half of unreachable-routes)
live, at the section-rail foot, because it holds both the config routes and the
redirects rail.
