Skip to content
DocspackagesDocumentation

@lunora/cli

The standalone `lunora` binary — scaffold, codegen, deploy.

PackagesCli

@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 DB

lunora 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:

ValueDescription
reactReact SPA: the official create-vite base + the Lunora layer (default)
vueVue SPA: create-vite base + Lunora
solidSolid SPA: create-vite base + Lunora
svelteSvelte SPA: create-vite base + Lunora
nextNext.js (App Router): OpenNext on Cloudflare + a standalone Lunora worker
tanstack-start-reactTanStack Start (React): SSR with live-loader routes
tanstack-start-solidTanStack Start (Solid)
react-routerReact Router v7 (framework mode): SSR composed into the Lunora worker
astroAstro + a standalone Lunora worker
analogAnalogJS (Angular): single-worker, Lunora mounted in Nitro
nuxtNuxt (Vue): single-worker, Lunora mounted in Nitro
sveltekitSvelteKit + a standalone Lunora worker
expoReact Native (Expo): an iOS/Android/web app + a Lunora worker backend
standaloneWorker 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 --source values outside gh:/github:/https://
  • --here: add Lunora to an existing project (detect the framework, patch the config, scaffold lunora/, 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.yml for GitHub Actions, .gitlab-ci.yml for GitLab CI), with a production lunora deploy on the default branch and a lunora deploy --preview on every pull / merge request. Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as 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)
FeatureInstallsNotes
auththe 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
emailthe mail registry itemCloudflare 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 studio

lunora 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 section

lunora 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 --json

lunora 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 the SHARD Durable Object binding.
  • D1 placeholders: a database_id still left at the scaffold placeholder.
  • Email destination: a send_email binding whose destination_address is 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 lunora running 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 stdout

It 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.

CodeLevelMeaning
admin-token-missinginfoLUNORA_ADMIN_TOKEN is not set (studio / admin RPCs stay disabled).
admin-token-setpassLUNORA_ADMIN_TOKEN is set.
cli-shadowedwarnThe running lunora is not the project's own install.
d1-placeholder-idfailA D1 binding still carries a scaffold database_id.
declared-export-missingfailA declared container / workflow / agent is not exported by the worker entry.
declared-export-okpassA declared container / workflow / agent is exported by the worker entry.
dev-vars-missing-secretwarn.dev.vars has secret-looking keys left at placeholder values.
email-destination-placeholderwarnA send_email binding has a placeholder destination_address.
vector-metadata-index-requiredinfoA declared Vectorize metadata filter needs its metadata index created.
vector-metadata-unfilterablewarnA metadata property has a type Vectorize cannot filter on.
version-counter-spreadinfoSame-channel @lunora/* pre-release counters differ (normal, worth noting).
version-skew-channelswarn@lunora/* packages mix release channels (stable + alpha).
version-skew-coreswarn@lunora/* packages span different major.minor.patch versions.
wrangler-missingfailNo wrangler.jsonc was found.
wrangler-shard-binding-missingfailwrangler.jsonc is missing the SHARD durable-object binding.
wrangler-shard-binding-okpasswrangler.jsonc declares the SHARD durable-object binding.
wrangler-unparseablefailwrangler.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 only

Background 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 record

When 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 answers

You 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-worker

lunora deploy --prebuilt skips codegen + the schema-drift gate (trusting the prior build / prepare); wrangler still bundles the Worker.

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 --remove

Once 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 claims

It 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 version

rollback 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 / failed

generate 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_TOKEN

Targeting 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 bindings

diff 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} envelopes

lunora 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 point

create, 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 --verify

A 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 seed

Seeding 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, overwrite

The 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 the HYPERDRIVE binding 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 becomes v.any() with a TODO beside 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: introspect cannot know who may read your data.
  • list is built on defineListArgs, 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 generated filter list 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.json

lunora 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 server

install 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:

ClientConfig file
claude-code.mcp.json
cursor.cursor/mcp.json
vscode.vscode/mcp.json
gemini.gemini/settings.json
claude-desktopthe 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:v1

build 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 staging

Writing Lunora functions by hand

Queries, mutations, and actions are plain TypeScript files under lunora/. There is no scaffolding command, so create the file yourself:

lunora/messages.ts
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 typed api.<file>.<function> namespace
  • server.ts: internalQuery, internalMutation exports re-typed against your schema
  • dataModel.ts: Doc<"messages">, Id<"users">, etc.

These files are deterministic, so commit them. CI re-runs codegen and diffs the result to catch drift.