@lunora/cli is the standalone alternative to the Vite plugin. It powers
the lunora binary you got when you ran npx lunorash@alpha init. The
plugin and the CLI share the same @lunora/codegen core, so the generated
files are identical regardless of how they were produced.
Commands
# Project
lunora init [name] [-t <template>] # scaffold a new project (default: react)
[-i | --yes] # offer (or skip) adding auth + email
lunora add <auth|email> # add a feature to the current project
lunora view [--remote] # open the Lunora studio in your browser
lunora docs [section] # open the docs site in your browser
lunora info [--json] # print versions, wrangler summary, schema overview
lunora doctor [--format <pretty|json>] # preflight the project (bindings, placeholders, secrets)
lunora registry <add|list|view|build> # component registry
lunora rules <install|check> # install the AI agent skills into .agents/skills/
# Develop
lunora mcp install [client…] # wire Lunora's MCP servers into your editor
[--list] [--print] [--force]
[--docs-only] [--local-only]
[--global | --project]
lunora mcp uninstall [client…] # remove them again
lunora mcp serve [--allow-writes] # the stdio MCP server your editor spawns
[--no-docs] [--url u] [--token t]
lunora dev [--port n] [--worker-port n] # wrangler worker + studio + codegen watch
[--no-studio] [--no-codegen]
lunora codegen [--api-spec <spec>] # one-shot codegen
[--format <pretty|json>]
lunora run <fn> [--args <json>] # send a single RPC to a running worker
[--shard <key>] [--url <u>]
[--as <userId>] [--claims <json>] # run as an identity (identity-gated apps)
lunora insights [--shard <key>] # write-conflict / error / latency hot-spots
[--limit n] [--json] [--prod --url <u>]
lunora reset [--all] [--yes] # clear local Miniflare state
# Deploy
lunora prepare [--api-spec <spec>] # codegen + wrangler validation — for CI
lunora build [--out-dir <dir>] # bundle the worker to disk without deploying
lunora deploy [--env <name>] # codegen, validate wrangler, then wrangler deploy
[--migrate] [--prebuilt] # --preview uploads a version (no live traffic)
[--preview] [--dry-run] # --dry-run validates + bundles, never publishes
[--temporary] # --temporary deploys with no account (~60min, then claim)
[--health-check] # --health-check probes /_lunora/health/ready after deploying
lunora link --url <url> [--env <name>] # link this checkout to its deployed worker
lunora deployments <list|inspect|rollback|promote> # history + traffic control
[--env <name>] [--yes]
lunora verify [--api-spec <spec>] # dry-run codegen + tsc --noEmit (no files written)
[--no-typecheck]
lunora analyze [--json] # wrangler dry-run: bundle size + top modules
lunora containers <build|push|images|list|info|delete> [args…]
[--tag <name:tag>] [--push] [--env <n>]
# Data
lunora logs [worker] # stream live Worker logs via wrangler tail
[--format <pretty|json>] # [--status <s>] [--search <q>] [--env <n>]
lunora migrate <generate|create|up|down|status> [name|id]
lunora env <list|get|set|unset|push|diff|doctor>
lunora export [--out <file>] [--tables <t1,t2>] [--url <u>] [--token <tok>]
lunora import <file> [--table <name>] [--batch-size n]
lunora backup <create|list|restore|pitr> [--dir <d>] [--bucket <b>] [--at <iso>]
lunora seed [--table <t>] [--count n] # deterministic fake data from schema.ts
[--seed n] [--dry-run] [--reset]
lunora introspect [--url <u>] [--tables <t1,t2>] # scaffold schema.ts from an
[--schema <s>] [--no-procedures] # existing Postgres/MySQL DBlunora init
Scaffolds a new Lunora project by fetching a template from
gh:anolilab/lunora/templates/<type>#<version>. init (and add) resolve
that release branch to the immutable commit SHA it currently points at (logged
as pinned … → <sha>), so the fetch is reproducible and tamper-evident; if the
SHA can't be resolved (offline / rate-limited) it falls back to the branch with
a one-line UNPINNED warning. Pass -t / --template to choose the starting
point:
| Value | Description |
|---|---|
react | React SPA: the official create-vite base + the Lunora layer (default) |
vue | Vue SPA: create-vite base + Lunora |
solid | Solid SPA: create-vite base + Lunora |
svelte | Svelte SPA: create-vite base + Lunora |
next | Next.js (App Router): OpenNext on Cloudflare + a standalone Lunora worker |
tanstack-start-react | TanStack Start (React): SSR with live-loader routes |
tanstack-start-solid | TanStack Start (Solid) |
react-router | React Router v7 (framework mode): SSR composed into the Lunora worker |
astro | Astro + a standalone Lunora worker |
analog | AnalogJS (Angular): single-worker, Lunora mounted in Nitro |
nuxt | Nuxt (Vue): single-worker, Lunora mounted in Nitro |
sveltekit | SvelteKit + a standalone Lunora worker |
expo | React Native (Expo): an iOS/Android/web app + a Lunora worker backend |
standalone | Worker only, no frontend |
The four create-vite frameworks (react, vue, solid, svelte) scaffold
through the overlay engine (the official create-vite base plus the Lunora
layer), while the rest are bespoke Lunora templates. Run lunora init with no
-t to pick from the same list interactively.
Additional flags:
--from <dir>: copy from a local templates root instead of fetching remotely (offline-friendly; expects<type>/subdirs)--source <ref>: override the remote template source (e.g.gh:owner/repo/sub#ref)--allow-unsafe-source: permit--sourcevalues outsidegh:/github:/https://--here: add Lunora to an existing project (detect the framework, patch the config, scaffoldlunora/, print per-framework wiring steps)-i/--interactive: after scaffolding, offer to add authentication and transactional email. Defaults on when stdin is a TTY; never prompts in CI.-y/--yes: skip the auth/email offer and scaffold only.--ci <github\|gitlab>: also scaffold a CI deploy pipeline (.github/workflows/deploy.ymlfor GitHub Actions,.gitlab-ci.ymlfor GitLab CI), with a productionlunora deployon the default branch and alunora deploy --previewon every pull / merge request. SetCLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_IDas the provider's secrets / CI-CD variables.
lunora add
Adds a feature to the current Lunora project (you must be inside one: a lunora/ directory and a wrangler.jsonc). A thin front door over lunora registry add: it maps a feature to its registry item(s), applies them, and prints the next steps.
lunora add auth # authentication (asks which provider)
lunora add auth --provider clerk # Clerk, without prompting (also: auth0, auth)
lunora add auth --yes # default provider (email & password), no prompt
lunora add email # transactional email (Cloudflare Email Workers + dev mail catcher)| Feature | Installs | Notes |
|---|---|---|
auth | the auth registry item (or auth-clerk / auth-auth0) | Adds @lunora/auth + a D1 DB binding; verification / reset mail is captured into the studio Mail tab in dev |
email | the mail registry item | Cloudflare Email Workers transport (SEND_EMAIL binding) + the dev mail catcher |
Flags: --provider <auth\|clerk\|auth0>, --yes, --from <dir> (local registry root), --source <ref>, --allow-unsafe-source.
lunora view
Opens the Lunora studio in your browser. Defaults to the local dev studio;
--remote opens the deployed worker's studio instead (resolved from
.lunora/project.json, see lunora link).
lunora view # local dev studio
lunora view --remote # the deployed studiolunora docs
Opens the documentation site in your browser. Pass an optional section path to jump straight to a page.
lunora docs # the docs home
lunora docs addons/studio # a specific sectionlunora info
Prints the resolved project configuration: the installed @lunora/* versions, a
wrangler.jsonc summary, and an overview of the tables declared in
lunora/schema.ts. --json emits the same snapshot machine-readably, useful in
bug reports and CI diagnostics.
lunora info
lunora info --jsonlunora doctor
A read-only preflight over the current project. It reports pass / warn / fail for:
wrangler.jsonc: that it exists, parses as JSONC, and declares theSHARDDurable Object binding.- D1 placeholders: a
database_idstill left at the scaffold placeholder. - Email destination: a
send_emailbinding whosedestination_addressis still a placeholder. .dev.vars: secrets present but left with unfilled values.LUNORA_ADMIN_TOKEN: whether it is set.- Containers: every container declared in your config is actually exported by the worker entry.
- Version skew:
@lunora/*packages spanning different versions, or mixing release channels (e.g. stable + alpha). - CLI shadowing: a globally-installed
lunorarunning instead of the project's own, so the report describes a project this CLI is not pinned to.
lunora doctor
lunora doctor --format json # machine-readable; one JSON document on stdoutIt writes nothing and exits non-zero when any hard check fails, so it works as a CI gate. See Debugging for how to act on each finding.
Machine-readable output
--format json prints exactly one JSON document on stdout; the human report is
still rendered, but on stderr, so the document stays pipeable. The exit code is
identical in both formats.
{
"code": 1,
"findings": [
{
"code": "d1-placeholder-id",
"fix": "Run `wrangler d1 create <name>` …",
"level": "fail",
"message": "D1 binding \"DB\" has a placeholder database_id …",
},
// …the info- and pass-level findings the summary counts
],
"ok": false,
"summary": { "fail": 1, "info": 1, "pass": 1, "warn": 0 },
}ok is redundant with code on purpose: it is the field a consumer without a
shell reaches for first. pass-level findings are included, so the document
describes everything that was checked, not only what went wrong.
Finding codes
Branch on finding.code, never on message: the codes are the contract, the
prose is not. New codes are added over time; treat an unknown one as advisory.
| Code | Level | Meaning |
|---|---|---|
admin-token-missing | info | LUNORA_ADMIN_TOKEN is not set (studio / admin RPCs stay disabled). |
admin-token-set | pass | LUNORA_ADMIN_TOKEN is set. |
cli-shadowed | warn | The running lunora is not the project's own install. |
d1-placeholder-id | fail | A D1 binding still carries a scaffold database_id. |
declared-export-missing | fail | A declared container / workflow / agent is not exported by the worker entry. |
declared-export-ok | pass | A declared container / workflow / agent is exported by the worker entry. |
dev-vars-missing-secret | warn | .dev.vars has secret-looking keys left at placeholder values. |
email-destination-placeholder | warn | A send_email binding has a placeholder destination_address. |
vector-metadata-index-required | info | A declared Vectorize metadata filter needs its metadata index created. |
vector-metadata-unfilterable | warn | A metadata property has a type Vectorize cannot filter on. |
version-counter-spread | info | Same-channel @lunora/* pre-release counters differ (normal, worth noting). |
version-skew-channels | warn | @lunora/* packages mix release channels (stable + alpha). |
version-skew-cores | warn | @lunora/* packages span different major.minor.patch versions. |
wrangler-missing | fail | No wrangler.jsonc was found. |
wrangler-shard-binding-missing | fail | wrangler.jsonc is missing the SHARD durable-object binding. |
wrangler-shard-binding-ok | pass | wrangler.jsonc declares the SHARD durable-object binding. |
wrangler-unparseable | fail | wrangler.jsonc was found but is not valid JSONC. |
lunora dev
Starts three concurrent processes: wrangler dev (Worker), the embedded
Lunora studio, and codegen in watch mode. All three reload on file changes.
In a project on @lunora/vite it spawns vite dev instead: the Vite plugin
already runs the worker, studio, and codegen inside the Vite dev server.
lunora dev # default ports: studio 6173, worker 8787
lunora dev --port 7000 # custom studio port
lunora dev --no-studio # Worker + codegen onlyBackground mode (AI agents)
--background starts the dev server as a managed detached process: the
command blocks until the server accepts requests, prints the URL + PID, then
returns. A state record at .lunora/dev.json acts as a lockfile: starting
again while a server runs reports the existing instance instead of spawning a
conflict, and stop / status / logs resolve the running instance from it.
Every subcommand is idempotent: stopping when nothing runs succeeds silently.
lunora dev --background # detach; blocks until ready, prints URL + PID
lunora dev status # URL, PID, uptime (add --json for a machine-readable doc)
lunora dev logs # captured output of a background run (--lines n, 0 = all)
lunora dev stop # SIGTERM, SIGKILL escalation after 10s; clears the recordWhen an AI coding agent is detected (Claude Code, Cursor, Codex, Gemini CLI,
Cline, …) background mode and JSON logging turn on automatically, so agent
workflows need no flags. Set LUNORA_AGENT_MODE=0 to opt out (or =1 to
force it). Agents can also poll GET /_lunora/status on the running worker, a
public, secret-free health probe answering {"ok":true}.
JSON log lines are available to everyone via lunora dev --json or
LUNORA_LOG_JSON=1.
lunora deploy
Runs codegen, validates wrangler.jsonc, then invokes wrangler deploy.
Pass --migrate to apply pending data migrations against the live worker
immediately after a successful deploy.
lunora deploy
lunora deploy --env staging
lunora deploy --migrate --migrate-token $LUNORA_ADMIN_TOKEN
lunora deploy --temporary # no Cloudflare account needed
lunora deploy --dry-run # run every pre-deploy gate, publish nothing
lunora deploy --health-check # …then prove the new version answersYou don't need a Cloudflare account to try a deploy. --temporary ships to a
temporary account (wrangler deploy --temporary): the Worker is live for about
60 minutes, then you either claim it into an account or it's deleted. An account
is only required to keep a deployment. (Wrangler errors if you're already
authenticated, so drop --temporary once you've signed in.)
--dry-run runs the full pre-deploy pipeline (codegen, the schema-drift gate,
wrangler.jsonc validation, and the wrangler bundle) without publishing.
A successful deploy auto-writes .lunora/project.json (see lunora link) from
the deployed URL, so follow-up commands don't need --url. Every real deploy
re-checks it: if the URL you just published to disagrees with the recorded one
(a custom domain added, the worker renamed), the deploy warns and keeps the
recorded value rather than rewriting an explicit lunora link. Run
lunora link --url <new> to accept the change. --temporary never writes a
link, since that account is gone in an hour.
--preview uploads a new Worker version (wrangler versions upload) and
reports its preview URL instead of going live: production traffic is untouched,
and the post-deploy steps (migrations, baseline re-bless, link write) are
skipped. The --ci pipelines use it to deploy a preview on every pull / merge
request.
--health-check
After a live deploy, probe the new version's health route
(/_lunora/health/ready, falling back to /_lunora/health on deployments
without a readiness gate) and fail the command when it never answers. Five
attempts, two seconds apart. A fresh version takes a moment to propagate, and a
fixed ceiling is what a CI timeout can be set against.
It is opt-in on purpose: a worker whose health route is admin-gated or unreachable from the runner must still be deployable, and a default-on network step would turn a good deploy into a red build for an unrelated reason.
A red probe exits non-zero and says which half failed: the deploy succeeded,
the probe did not. The probe runs before --migrate, so a worker that can't
serve is never migrated.
Machine-readable output
--format json writes exactly one JSON document to stdout (every human line,
including wrangler's own output, goes to stderr). It carries a deployment
object describing what this run put where:
{
"code": 0,
"deployment": {
"deployedAt": "2026-08-08T09:12:33.417Z",
"dryRun": false,
"env": "production",
"preview": false,
"url": "https://my-app.acme.workers.dev",
"workerName": "my-app",
},
"healthCheck": { "ok": true, "url": "https://my-app.acme.workers.dev/_lunora/health/ready" },
// …validation, schemaDrift, mintedSecretsFile
}dryRun and preview are always present, so a consumer can tell "nothing went
live" from "went live" without inferring it from a missing url. There is no
version id: the pinned wrangler has no structured deploy output, and
lunora deployments list is the supported way to read one.
A release pipeline reads the URL out of the same document it already checks the exit code of:
set -euo pipefail
result=$(lunora deploy --env production --format json --health-check)
url=$(echo "$result" | jq -r '.deployment.url')
# The document is printed on failure too, and a failed deploy carries no URL —
# without this guard `jq` yields "null" and the smoke test requests null/api/smoke,
# so the pipeline fails at the wrong step.
if [ -z "$url" ] || [ "$url" = "null" ]; then
echo "deploy reported no URL" >&2
exit 1
fi
curl -fsS "$url/api/smoke"lunora build
Runs the full pre-deploy pipeline (codegen, the schema-drift gate, wrangler.jsonc
validation) and writes the bundled Worker to disk without publishing
(wrangler deploy --dry-run --outdir). This is the build half of a build/deploy
split: produce a verified artifact in one CI step, then ship it with
lunora deploy --prebuilt in another.
lunora build # bundle to .lunora/build
lunora build --out-dir dist-workerlunora deploy --prebuilt skips codegen + the schema-drift gate (trusting the
prior build / prepare); wrangler still bundles the Worker.
lunora link
Records the deployed Worker's name + public URL in a gitignored
.lunora/project.json, so commands that target a live worker stop needing
--url on every invocation.
lunora link --url https://my-app.acme.workers.dev
lunora link --url https://my-app.acme.workers.dev --env production
lunora link --removeOnce linked, lunora run, lunora logs, and lunora deploy --migrate resolve
the worker from the link automatically. The bulk / destructive commands
(export, import, migrate, backup, seed, insights) use the link only
under --prod, so a production link never silently becomes the target of an
unguarded write. The link carries only public identifiers, never secrets.
Calling a function that requires a signed-in user
lunora run sends an anonymous RPC. Any app that configures authorizeShard
(the recommended posture) default-denies that, so the call comes back
FORBIDDEN_SHARD no matter what you pass.
--as is the way through: it dispatches via the admin-gated runAs op, which
forges the named identity for that one call, so the function and any RLS
middleware observe that user instead of an anonymous caller.
lunora run messages:list --as user_123
lunora run messages:list --as user_123 --claims '{"org":"acme"}' # extra identity claimsIt needs the admin bearer, resolved in this order: --token, then
LUNORA_ADMIN_TOKEN, then .dev.vars. The last applies only to a loopback
target, so a dev secret is never sent to a deployed worker. Against your own dev
server that means no flags at all.
Identity forging is admin-gated, refuses to target reserved admin functions, and is recorded in the shard's audit trail. Treat the admin bearer accordingly.
lunora deployments
Inspect deployment history and move traffic between Worker versions (wraps
wrangler versions / wrangler rollback):
lunora deployments list # 10 most recent deployments
lunora deployments inspect <version-id> # view a specific Worker version
lunora deployments rollback --yes # roll back to the previous version
lunora deployments promote <version-id> --yes # send 100% of traffic to a versionrollback and promote change live traffic, so they require --yes.
lunora prepare
Same pipeline as lunora deploy but stops before wrangler deploy, with no
Vite step and no network traffic. Use it in CI to catch codegen drift and
wrangler.jsonc validation errors before the deploy job runs.
lunora verify
Validates wrangler.jsonc, runs a codegen dry-run, and type-checks the
project with tsc --noEmit. Nothing is written to disk. Exits non-zero on
any error so you can gate merges on it.
lunora migrate
Manages both schema migrations (D1 SQL) and online data migrations (row transforms that run against a live worker):
lunora migrate generate add_email_index # diff schema.ts → emit D1 SQL
lunora migrate create --name backfill_at # scaffold a data-migration stub
lunora migrate up # run pending data migrations (dev)
lunora migrate up --prod --url <url> # run against production (requires --yes)
lunora migrate status # show pending / applied / failedgenerate parses lunora/schema.ts, filters to .global() tables (sharded
tables live in per-DO SQLite, so they need no migration), diffs against
lunora/migrations/.snapshot.json, and emits a timestamped SQL file. Commit
both the SQL and the snapshot: they are deterministic.
lunora run
Send a single RPC to a running Worker without spinning up the client SDK:
lunora run messages:send --args '{"channelId":"general","text":"hi"}'--shard overrides shard routing; --url lets you point at a deployed
Worker instead of http://localhost:8787.
lunora codegen
Runs codegen once and exits, the one-shot form of what lunora dev and the
Vite plugin do in watch mode. It reads lunora/schema.ts plus your function
files and writes lunora/_generated/.
lunora codegen
lunora codegen --api-spec both # also emit openapi.json and openrpc.json
lunora codegen --format json # machine-readable output--api-spec selects which API description to emit alongside the generated
modules: openapi (the default) writes openapi.json, openrpc writes
openrpc.json, both writes both, and none writes neither.
lunora insights
Reports per-function metrics from a running worker, ranked into three sections:
- Write-conflict hot-spots: functions that lose OCC retries most often. These are your sharding candidates: a high conflict rate means many writers are contending on one Durable Object.
- Error hot-spots: functions by error rate, with the most recent error message.
- Latency outliers: slowest single call, plus the mean, per function.
lunora insights # against the local dev worker
lunora insights --shard channel:demo # scope to one shard
lunora insights --limit 25 # more rows per section (default 10)
lunora insights --json # raw report
lunora insights --prod --url https://app.example.com --token $LUNORA_ADMIN_TOKENTargeting production requires an explicit --url. See
Sharding for what to do about a write-conflict
hot-spot, and Performance for the latency side.
lunora reset
Clears local Miniflare state: the simulated Durable Objects, D1, R2, and KV
that lunora dev persists under .wrangler/state. This is the "start from an
empty database" button for local development; it never touches a deployed
worker.
lunora reset # clear Miniflare state (prompts to confirm)
lunora reset --all # also remove .lunora-cache
lunora reset --yes # skip the prompt (required when stdin is not a TTY)lunora env
Manage .dev.vars (local secrets) and push them to Cloudflare via wrangler secret:
lunora env list # list all keys in .dev.vars
lunora env get DATABASE_URL # read a single key
lunora env set FOO bar # write a key
lunora env unset FOO # remove a key
lunora env push # upload to Cloudflare (prompts unless --yes)
lunora env push --prod --yes # push to the production environment
lunora env diff # compare local .dev.vars keys against Cloudflare
lunora env doctor # validate .dev.vars against wrangler.jsonc bindingsdiff reads the deployed Worker's secret names via wrangler secret list
and reports which keys are local-only (need a push), remote-only, or in both.
Cloudflare never returns secret values (they are write-only), so diff
compares names, not values. Pass --prod to target the production environment.
lunora export / lunora import
Bulk data transfer between workers, mirroring Convex's convex export /
convex import:
lunora export --out ./backup.ndjson
lunora export --tables messages,channels --url https://my-worker.workers.dev --token $TOKEN
lunora import ./backup.ndjson
lunora import ./users.ndjson --table users # wrap bare docs as {table,doc} envelopeslunora backup
Managed snapshot backups and native point-in-time recovery (PITR):
lunora backup create # snapshot the running shard
lunora backup list # list available snapshots
lunora backup restore <id> # restore a snapshot
lunora backup retention # what a prune would delete
lunora backup prune # delete it — the only command that removes a backup
lunora backup pitr --at 2024-06-01T12:00:00Z # read PITR bookmark
lunora backup pitr --at 2024-06-01T12:00:00Z --restore --yes # restore to that pointcreate, list and restore write to a directory (--dir, default
.lunora-backups) or to an R2 bucket in your account (--bucket <name>,
--prefix, default prefix backups/). The snapshot is identical either way;
only the destination changes, and bucket traffic goes through the worker's
admin storage routes under the admin bearer, so the CLI holds no R2
credentials. restore --verify checks the snapshot's SHA-256 against the
manifest before importing anything, and fails when no checksum was recorded.
One invocation reads one destination: a bucket and a directory are never
merged into one listing.
lunora backup create --bucket default --tables users,messages
lunora backup list --bucket default
lunora backup restore 2026-06-01T12:00:00.000Z --bucket default --verifyA backup id is the ISO timestamp the snapshot was taken at
(2026-06-01T12:00:00.000Z). That is what list prints first and what
restore matches. The filename / object key is the same timestamp with :
and . swapped for -, because those characters are awkward in filenames
(lunora-backup-2026-06-01T12-00-00-000Z.ndjson). restore takes either: an id
it finds in the manifest, or a path/key you name directly.
A bucket-backed snapshot goes through the checksum-verified admin upload route,
which takes one body and caps it at 32 MiB. Above that create --bucket refuses
and names the workaround (--tables to narrow the snapshot, or --dir plus
wrangler r2 object put to move the file yourself). The alternative (the
signed-PUT fallback the blob importer uses for large files) is not
checksum-verified and needs URL signing configured, which is not what the copy
you restore from should depend on.
See Backups, export & import for the full picture: snapshot format, restore drills, and moving data between deployments.
lunora seed
Generates deterministic fake data from lunora/schema.ts and bulk-inserts it
through the worker's admin endpoint. Rows are derived from a seed number, so the
same --seed always produces identical data, which makes seeded fixtures
reproducible across machines and CI runs.
lunora seed # every table, default 10 rows each
lunora seed --table posts --count 50 # 50 posts; FK parents seeded automatically
lunora seed --seed 7 --dry-run # print the NDJSON for seed 7, insert nothing
lunora seed --reset # wipe local .wrangler/state, then seedSeeding respects foreign keys: naming one table with --table also seeds the
tables it references. --batch-size (default 500) sets rows per HTTP request.
Targeting a non-local worker needs --prod plus an explicit --url, and prompts
unless you pass --yes; prefer LUNORA_ADMIN_TOKEN over --token, which is
visible to other local processes through the process table. See
@lunora/seed for using the same generator inside tests.
lunora introspect
Reads an existing Postgres or MySQL database and scaffolds lunora/schema.ts
from it, plus a list/get procedure module per table. Use it when you're
adopting Lunora on top of a database that already exists, instead of
transcribing the schema by hand.
lunora introspect --url postgres://localhost/shop # every base table
lunora introspect --tables users,orders # just these (reads $DATABASE_URL)
lunora introspect --dry-run # print, write nothing
lunora introspect --no-procedures --force # schema only, overwriteThe command is read-only against the source database: it queries
information_schema (and pg_index on Postgres) and never writes.
Re-runs merge, they don't clobber. The first run writes a whole
lunora/schema.ts; after that the file is yours, and a second run folds only
what's new (tables, columns, indexes) into it as additive edits, preserving
your formatting, comments, and any validator you tightened. That goes through the
same ts-morph editor the Studio schema editor uses, so two of its rules apply: a
column added to a table that already exists lands v.optional(...) (a required
one needs a backfill migration), and names that aren't bare identifiers are
reported and skipped. Nothing is ever removed: a column dropped upstream stays,
because deleting it would drop rows. Pass --force to overwrite instead of merge,
and --dry-run to see the plan first.
What it emits is a starting point you own, not a build artifact: review it before shipping. Specifically:
- Every table is
.global({ backend: "hyperdrive" }), because the rows live in the external database. Point theHYPERDRIVEbinding at it; see @lunora/hyperdrive. - Lunora mints its own
_id, so the source primary key is carried over as a unique index rather than replacing it. - Foreign keys become
v.id("<target>"); a type with no direct validator becomesv.any()with aTODObeside it, and is reported as a warning. - The emitted procedures are RPC-only. Publishing one over REST stays an
explicit
.expose({ rest: true })decision you make after adding whatever auth or RLS the table needs:introspectcannot know who may read your data. listis built ondefineListArgs, so only index-backed columns are filterable and paging is keyset-based. That bounds which columns a caller can reach, not the cost of every operator over them. Review the generatedfilterlist before exposing the procedure.
The driver is loaded on demand and is not a CLI dependency: install pg or
mysql2 in your project first.
lunora registry
Fetch and manage reusable Lunora components (queries, mutations, UI widgets):
lunora registry list
lunora registry view auth/session-token
lunora registry add auth/session-token
lunora registry build # regenerate the local catalog index.jsonlunora rules
Installs the Lunora agent skills into the project's .agents/skills/: they
are portable instructions that teach AI coding agents (Claude Code, Cursor,
Copilot) how to use Lunora. lunora dev, the Vite plugin, and the studio nudge
you to run this when the rules are missing.
lunora rules install # copy the skills into .agents/skills/ (skips edited files)
lunora rules install --overwrite # reinstall, replacing local edits
lunora rules check # report which skills are present
lunora rules check --strict # exit non-zero when missing (CI gate)lunora mcp
Connects AI editors to Lunora over the
Model Context Protocol. Where lunora rules
teaches an agent how Lunora works, this gives it live tools: documentation
search, dev-server status and logs, and typed access to your app's functions.
lunora mcp install # every MCP client already configured here
lunora mcp install claude-code cursor # or name them
lunora mcp install --list # supported clients and their config files
lunora mcp install --print # show the config without writing it
lunora mcp install --force # replace entries that already exist
lunora mcp install --docs-only # skip this project's local serverinstall writes two servers, and they go to different places by default:
lunora-docs, the hosted documentation server at https://lunora.sh/mcp,
is the same URL in every project, so it lands in the client's machine-wide
config; lunora, which runs lunora mcp serve for this app, only means
anything inside the project, so it lands in the project config. Pass
--global or --project to force both one way. Where a client only has one of
the two (Zed has no project config, VS Code no global one), it falls back rather
than skipping the client.
lunora mcp uninstall removes both again, from both scopes and every client by
default. It touches only the two entries we wrote, leaving the rest of the file
and its comments alone. It knows each client's own
config file, top-level key, and entry shape, so you don't have to remember that
VS Code says servers where Cursor says mcpServers:
| Client | Config file |
|---|---|
claude-code | .mcp.json |
cursor | .cursor/mcp.json |
vscode | .vscode/mcp.json |
gemini | .gemini/settings.json |
claude-desktop | the OS application-data directory |
windsurf | ~/.codeium/windsurf/mcp_config.json |
codex | ~/.codex/config.toml (snippet printed to paste) |
Existing entries are left alone unless you pass --force, comments in a JSONC
config survive the edit, and a file that doesn't parse is reported rather than
overwritten.
lunora mcp serve is the stdio server those entries spawn; you rarely run it
by hand. It takes no required configuration: the dev server's URL comes from
.lunora/dev.json and the admin token from .dev.vars, both re-read per tool
call, so starting lunora dev after your editor is already open just works. It
exposes the documentation tools, lunora_dev_status / lunora_dev_logs, and the
read-only deployment tools; --allow-writes adds the mutation and action tools.
lunora analyze
Runs a wrangler dry-deploy and reports bundle size, the heaviest modules,
and the state of _generated/ files. Pass --json to pipe the output to a
CI artifact.
lunora containers
Thin wrappers over wrangler containers …, so container image and instance
management lives under the same CLI as the rest of the deploy workflow. The
split matters in CI: build and push the image in one step, then ship the worker
with lunora deploy in another.
lunora containers build ./containers/transcoder --tag transcoder:v1
lunora containers build ./containers/transcoder --tag transcoder:v1 --push
lunora containers push transcoder:v1
lunora containers images list
lunora containers images delete transcoder:v1build uses your local Docker engine; --push uploads to the Cloudflare
Registry in the same step. --env selects the Cloudflare environment. See
@lunora/container for declaring containers in the
first place.
lunora logs
Streams live logs from a deployed Lunora Worker by wrapping wrangler tail:
lunora logs
lunora logs my-worker --format json --status error
lunora logs --search "auth" --env stagingWriting Lunora functions by hand
Queries, mutations, and actions are plain TypeScript files under lunora/.
There is no scaffolding command, so create the file yourself:
import { mutation, query, v } from "@/lunora/_generated/server";
export const list = query.input({ channelId: v.id("channels"), limit: v.optional(v.number()) }).query(async ({ ctx, args: { channelId, limit } }) => {
return ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.take(limit ?? 50);
});
export const send = mutation.input({ channelId: v.id("channels"), text: v.string() }).mutation(async ({ ctx, args: { channelId, text } }) => {
await ctx.db.insert("messages", {
channelId,
userId: ctx.auth.userId!,
text,
createdAt: Date.now(),
});
});After saving, lunora dev picks up the change and re-runs codegen
automatically. Run lunora codegen once manually if you are outside the dev
loop.
Codegen output
lunora/_generated/:
api.ts: the typedapi.<file>.<function>namespaceserver.ts:internalQuery,internalMutationexports re-typed against your schemadataModel.ts:Doc<"messages">,Id<"users">, etc.
These files are deterministic, so commit them. CI re-runs codegen and diffs the result to catch drift.