@lunora/cli
Version:
The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands
1,126 lines • 58.3 kB
text/typescript
import { CodegenOptions, Finding, SchemaIR } from '@lunora/codegen';
import { ensureDevVariables, ensureDevVarsExample, fillDevSecrets, LintTool, LintIgnoreOutcome } from '@lunora/config';
import { materializeRemoteWranglerConfig } from '@lunora/config/cloudflare';
export { REQUIRED_COMPATIBILITY_DATE, REQUIRED_FLAG, type WranglerProjectValidationOptions as WranglerValidationOptions, type WranglerValidationReport, type WranglerProjectValidationResult as WranglerValidationResult, validateWranglerProject as validateWrangler, validateWranglerConfig } from '@lunora/config/cloudflare';
/** Every command name the CLI registers (drives the `CommandName` type + tests). */
declare const COMMANDS: readonly ["init", "add", "dev", "codegen", "build", "deploy", "containers", "prepare", "link", "deployments", "logs", "run", "insights", "reset", "migrate", "export", "import", "seed", "backup", "eval", "verify", "info", "doctor", "env", "analyze", "view", "docs", "registry", "rules", "mcp"];
type CommandName = (typeof COMMANDS)[number];
declare const VERSION: string;
interface RunCliOptions {
argv?: ReadonlyArray<string>;
cwd?: string;
/**
* Inject a console-like logger so callers (tests) can capture cerebro's
* help / version / usage rendering. Omitted in production, where cerebro
* uses its default stdout/stderr logger.
*/
logger?: Console;
}
/**
* Run the CLI and resolve to the process exit code. cerebro handles help,
* version, usage, and unknown commands (the latter throws, caught here as 1).
* `shouldExitProcess: false` keeps the process alive so callers/tests read the
* captured exit code.
*/
declare const runCli: (options?: RunCliOptions) => Promise<number>;
/**
* The `--api-spec` flag's accepted values, mirroring `@lunora/codegen`'s
* `CodegenOptions["apiSpec"]`. `"openapi"` (the default) emits `openapi.json`;
* `"openrpc"` emits `openrpc.json`; `"both"` emits both; `"none"` emits neither.
*/
type ApiSpec = NonNullable<CodegenOptions["apiSpec"]>;
interface Logger {
debug?: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
success: (message: string) => void;
warn: (message: string) => void;
}
/**
* Narrowed view over the pail instance. `createPail` returns an intersection
* type that includes a constructor signature and `(...args: any[])` logger
* overloads, which the type-aware linter cannot safely resolve. We only ever
* call the level methods with a string, so we describe exactly that surface.
*/
interface PailLogger {
debug: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
success: (message: string) => void;
warn: (message: string) => void;
}
declare const createLogger: () => Logger;
/**
* Direct access to the underlying pail instance for advanced use-cases.
* A Proxy keeps the public `pail` binding lazy: the real pail is only
* constructed on first property access, so importing this module (and thus
* the package barrel) stays side-effect-free.
*/
declare const pail: PailLogger;
interface CodegenCommandOptions {
/** Which API spec(s) to emit. Defaults to codegen's `"openapi"` when omitted. */
apiSpec?: ApiSpec;
cwd?: string;
/** Output format: `pretty` (default) or `json`. */
format?: string;
logger: Logger;
/**
* Fail the run when any ERROR-level advisory is reported. Defaults to CI
* detection so a local `lunora codegen` stays advisory while a pipeline
* gates on it; `--no-strict-advisories` forces it off either way.
*/
strictAdvisories?: boolean;
/** Deploy target the emitted `ctx.*` surface is tailored to. Resolved by the caller; falls back to `"target"` in `lunora.json`, then `"cloudflare"`. */
target?: string;
}
interface CodegenCommandResult {
advisories: ReadonlyArray<{
detail: string;
level: Finding["level"];
name: string;
remediation: string;
}>;
cronTriggers: ReadonlyArray<string>;
/** Set when the run failed: an invalid `--format`, an unregistered target, or an error-level platform diagnostic. */
error?: string;
/** ERROR-level advisories that made the run fail, when strict mode is on. */
failedAdvisories: number;
outputDirectory: string;
}
declare const runCodegenCommand: (options: CodegenCommandOptions) => CodegenCommandResult;
/**
* Minimal projection of `globalThis.fetch` for the transfer commands: `body` is
* exposed as a stream-iterable (the export path pipes it) and accepts bytes (the
* blob path uploads them). The JSON-only commands use the narrower `FetchLike`
* in `../run/handler` instead.
*/
type StreamingFetchLike = (input: string, init?: {
body?: string | Uint8Array;
headers?: Record<string, string>;
method?: string;
}) => Promise<{
/** Optional: only the storage transfer reads raw bytes, and only real `fetch` needs to supply it. */
arrayBuffer?: () => Promise<ArrayBuffer>;
body: ReadableStream<Uint8Array> | null;
json: () => Promise<unknown>;
ok: boolean;
status: number;
text: () => Promise<string>;
}>;
interface ExportCommandOptions {
cwd?: string;
fetchImpl?: StreamingFetchLike;
logger: Logger;
/** Output file path; `undefined`/`-` streams to stdout. */
out?: string;
/** Guardrail: refuse to target localhost when set. */
prod?: boolean;
/** Comma-separated table list; omit to export every table. */
tables?: string;
/** Admin bearer token (or `LUNORA_ADMIN_TOKEN`). */
token?: string;
/** Worker URL (default `http://localhost:8787`). */
url?: string;
}
interface ExportCommandResult {
bytes: number;
code: number;
/** Number of NDJSON lines streamed (0 on error). */
rows: number;
}
/**
* Stream an export. The worker emits NDJSON; we count newlines as we go and
* pipe straight to the output sink, so a 10M-row export doesn't materialise
* the body in memory.
*/
declare const runExportCommand: (options: ExportCommandOptions) => Promise<ExportCommandResult>;
/** One row-scoped failure as the admin import endpoint reports it. */
interface ImportRowError {
code: string;
line: number;
message: string;
table: string;
}
/**
* The sources `--from` accepts.
*
* Only the two that cannot be detected. A Convex snapshot announces itself (a
* directory of `<table>/documents.jsonl`, or a `.zip` of one) and anything else
* is NDJSON, so naming those would advertise a control this does not implement:
* `--from ndjson` against a Convex export would have to either refuse it or
* silently import it as Convex, and the second is what an unhonoured flag
* actually did.
*/
declare const IMPORT_SOURCE_NAMES: readonly ["firebase", "supabase"];
type ImportSourceName = (typeof IMPORT_SOURCE_NAMES)[number];
/**
* The storage-reference rewrite: turning a Convex storage id into the
* content-hash R2 key its blob was migrated to.
*
* Split out of `./storage-mapping` (which owns the mapping *file*) because it
* has two callers that must never diverge — the import rewrite and `--scan`,
* which runs this same walk as a dry run to propose the mapping. A detector
* that proposed columns the rewrite would not touch, or missed ones it would,
* is worse than no detector.
*/
/** One reference the walk could not rewrite, with where it was found. */
interface UnresolvedStorageReference {
column: string;
storageId: string;
table: string;
}
/**
* What a run's storage references resolved to. The two failure buckets are
* deliberately separate, because they are not the same problem and do not have
* the same remedy:
*
* `unmigrated` is a reference to a blob that does not exist — the export omitted
* it, or `--include-file-storage` was not passed. Nothing the operator writes in
* a mapping file can fix it, and the data is broken after import, so it fails
* `--verify`.
*
* `ambiguous` is a string that exactly matches a blob that *did* migrate, sitting
* in a column the mapping does not name. It may be a storage reference the
* mapping forgot, or it may be user text that happens to equal an id. Failing the
* run on a coincidence is not defensible, so it warns and names the column the
* operator would add to resolve it.
*/
interface StorageRemapReport {
ambiguous: UnresolvedStorageReference[];
/** Number of references rewritten to a content-hash key. */
rewritten: number;
unmigrated: UnresolvedStorageReference[];
}
/** Rows per HTTP request when importing. Convex uses ~500; same here. */
declare const DEFAULT_IMPORT_BATCH_SIZE = 500;
interface ImportCommandOptions {
/** Rows per HTTP request. Defaults to {@link DEFAULT_IMPORT_BATCH_SIZE}. */
batchSize?: number;
cwd?: string;
fetchImpl?: StreamingFetchLike;
/** Source NDJSON file. Required. */
file: string;
/**
* Which reader to use. Omit to auto-detect between a Convex export snapshot
* and a plain NDJSON file; `supabase`/`firebase` must be explicit, because a
* directory of CSV or JSON has no signature that distinguishes it from
* anything else a user might point at.
*/
from?: ImportSourceName;
logger: Logger;
prod?: boolean;
/**
* Scan the export for columns holding `_storage` ids and write a candidate
* `lunora/import-convex.json`. Scan-only: nothing is imported.
*/
scan?: boolean;
/**
* Local directory of storage objects to migrate alongside the rows — how
* Firebase Cloud Storage arrives, after `gcloud storage cp -r`.
*/
storageDir?: string;
/**
* Wrap each line as `{table:<name>,doc:<line>}`. Use when the source NDJSON
* is bare docs from a single table — Convex's `convex import --table users`
* shape.
*/
table?: string;
token?: string;
url?: string;
/**
* Verify per-table row parity + dangling-storage after import. Exits non-zero
* when a table's inserted count differs from its source line count, or when a
* document references a storage id that was not migrated.
*/
verify?: boolean;
/**
* Also migrate Convex `_storage` blobs: read `_storage/documents.jsonl`, upload
* each blob with sha256+size verification, and build the `storageId → key` map.
* Off by default so the plain-document import path is unchanged.
*/
withStorage?: boolean;
/** Confirm bulk-writing production. Required alongside `--prod`. */
yes?: boolean;
}
/**
* The JSON summary a run prints and returns — the same object either way, so a
* caller reading `body.conflicts` does not have to cast its way there.
*
* `undefined` on every path that imports nothing: a rejected source, a failed
* storage phase, or `--scan` (whose product is the mapping file it writes, not
* a return value).
*/
interface ImportSummary {
conflicts: number;
errors: ImportRowError[];
inserted: Record<string, number>;
received: number;
storage?: {
ambiguous: StorageRemapReport["ambiguous"];
blobs: number;
rewritten: number;
unmigrated: StorageRemapReport["unmigrated"];
};
warnings?: string[];
}
interface ImportCommandResult {
body: ImportSummary | undefined;
code: number;
/** Total inserted rows across batches. */
inserted: number;
}
declare const runImportCommand: (options: ImportCommandOptions) => Promise<ImportCommandResult>;
/**
* Injectable probe for a Docker-compatible container engine. Tests pass a
* stub; production uses {@link isDockerAvailable}.
*/
type DockerProbe = () => boolean;
/**
* The shared `/_lunora/health` probe used by `lunora verify --health-url` and
* `lunora deploy --health-check`.
*
* Both commands ask the same question — "does this deployment answer?" — so
* they ask it through one implementation with one error-message shape. The
* runtime auto-registers both routes (`packages/runtime/src/health-routes.ts`):
* `/_lunora/health/ready` is the readiness gate ("can this version serve"), and
* `/_lunora/health` is the aggregate that also exists on older deployments.
*
* The probe is transport-only: it never throws, and reports its verdict as an
* `{ error }` message the caller decides what to do with.
*/
/**
* Minimal fetch surface the probe needs — a subset of the global `fetch`,
* injectable so a test can feed a canned response without a network.
*/
type HealthFetch = (url: string) => Promise<{
ok: boolean;
status: number;
}>;
interface SpawnDescriptor {
args: ReadonlyArray<string>;
/**
* Capture the child's stderr (in addition to streaming it to the parent).
* Needed when a tool reports the *expected* outcome as an error there —
* `wrangler vectorize create-metadata-index` writes "already exists" to
* stderr, and without this the caller can only see a bare exit code and
* would warn on every re-run. Composes with `stdoutToStderr`, so a caller
* can keep stdout clean for `--format json` and still read the reason.
*/
captureStderr?: boolean;
/**
* Capture the child's stdout (in addition to streaming it to the parent), so
* the caller can parse it — used by `deploy` to read the deployed URL from
* `wrangler deploy` output. Each chunk is still teed to the parent's stdout
* so the user sees live progress. Mutually exclusive with `stdoutToStderr`
* and `captureStdoutSilently`.
*/
captureStdout?: boolean;
/**
* Capture the child's stdout WITHOUT teeing it to the parent's stdout —
* for output that is parsed, never displayed (e.g. `wrangler secret list
* --format json`). Unlike `captureStdout`, nothing is written to
* `process.stdout`, so it can't interleave with — and corrupt — a
* caller's own stdout (notably `lunora deploy --format json`, which must
* emit exactly one JSON document). Mutually exclusive with `captureStdout`
* and `stdoutToStderr`.
*/
captureStdoutSilently?: boolean;
command: string;
cwd?: string;
env?: Readonly<Record<string, string>>;
/**
* Pipe this string into the child's stdin and close it. Used to feed
* `wrangler secret put` its value without exposing it on the command
* line or in env. When absent, stdin is inherited from the parent.
*/
input?: string;
/**
* Route the child's stdout to the parent's STDERR instead of stdout. Set in
* `--format json` mode so a spawned tool's human output (e.g. `wrangler
* deploy`'s progress + the deployed URL) can't interleave with — and corrupt
* — the single JSON document the command prints to stdout.
*/
stdoutToStderr?: boolean;
}
interface SpawnResult {
code: number;
/** The captured stderr, present only when the descriptor set `captureStderr`. */
stderr?: string;
/** The captured stdout, present only when the descriptor set `captureStdout` or `captureStdoutSilently`. */
stdout?: string;
}
/**
* Injectable spawner. Tests pass a stub that just records the descriptor
* instead of executing a real subprocess.
*/
type Spawner = (descriptor: SpawnDescriptor) => Promise<SpawnResult>;
declare const defaultSpawner: Spawner;
interface RecordedSpawn {
descriptor: SpawnDescriptor;
}
/**
* Test helper: returns a spawner that records every invocation and resolves
* with the configured exit code.
*/
declare const createRecordingSpawner: (exitCode?: number) => {
calls: RecordedSpawn[];
spawner: Spawner;
};
interface SecretListRunnerResult {
code: number;
stderr: string;
stdout: string;
}
/** Runs an argv and resolves its captured output. Injected in tests. */
type SecretListRunner = (command: string, args: ReadonlyArray<string>, cwd: string) => Promise<SecretListRunnerResult>;
interface ListRemoteSecretsInputs {
cwd: string;
/** Cloudflare environment name (`--env`). */
env?: string;
/** Injected command runner; defaults to a real `wrangler secret list`. */
runner?: SecretListRunner;
/** Target a temporary-account deployment (`--temporary`). */
temporary?: boolean;
}
interface ListRemoteSecretsResult {
/** Diagnostic message when `ok` is false. */
error?: string;
/** Remote secret names (sorted), empty when none or on failure. */
names: ReadonlyArray<string>;
/** False when wrangler failed or its output could not be parsed. */
ok: boolean;
}
type FetchLike = (input: string, init?: {
body?: string;
headers?: Record<string, string>;
method?: string;
}) => Promise<{
json: () => Promise<unknown>;
ok: boolean;
status: number;
text: () => Promise<string>;
}>;
interface RunCommandOptions {
args?: string;
/** Forge this user id for the call (dispatches through the admin-gated `runAs` op). */
as?: string;
/** JSON-encoded extra identity claims to accompany {@link RunCommandOptions.as}. */
claims?: string;
cwd?: string;
fetchImpl?: FetchLike;
functionPath: string;
logger: Logger;
shard?: string;
/** Admin bearer for the `runAs` dispatch; resolved from the environment / `.dev.vars` when absent. */
token?: string;
url?: string;
}
interface RunCommandResult {
body: unknown;
code: number;
requestUrl: string;
}
declare const runRpcCommand: (options: RunCommandOptions) => Promise<RunCommandResult>;
interface DeployCommandOptions {
/** Override the schema-drift gate — deploy even with breaking drift and no new migration. */
allowSchemaDrift?: boolean;
/** Which API spec(s) codegen emits. Defaults to codegen's `"openapi"` when omitted. */
apiSpec?: ApiSpec;
cwd?: string;
/** Docker-availability probe injected in tests. Defaults to a real `docker info` check. */
dockerAvailable?: DockerProbe;
/**
* Validate, bundle, and run all pre-deploy gates without publishing
* (`wrangler deploy --dry-run`). Post-deploy steps (data migrations, schema
* baseline re-bless) are skipped since nothing shipped.
*/
dryRun?: boolean;
env?: string;
/** Fetch implementation injected in tests for `--migrate` RPC calls. */
fetchImpl?: FetchLike;
/** Output format: `pretty` (default) or `json`. */
format?: string;
/**
* After a successful live deploy, probe the new version's health route
* (`/_lunora/health/ready`, falling back to `/_lunora/health`) and fail the
* command when it never answers. Opt-in, not default-on: a worker whose
* health route is admin-gated or unreachable from CI must still be
* deployable, and a default network step would turn a successful deploy
* into a red build for an unrelated reason.
*/
healthCheck?: boolean;
/** Injectable fetch for `--health-check`; defaults to the global `fetch`. */
healthFetch?: HealthFetch;
/** Injectable inter-attempt delay for `--health-check`; injected in tests to skip the real wait. */
healthSleep?: (ms: number) => Promise<void>;
/** Set to `false` to disable interactive spinners (test injection). */
interactive?: boolean;
logger: Logger;
/**
* When true, after a successful `wrangler deploy`, discover and run all
* pending data migrations via the worker's `/_lunora/migrate` admin RPC.
* The worker must be live (exit 0) before migrations are attempted.
*
* Implementation note: the status RPC returns the full shard-level
* migration state, but there is no single authoritative "list of pending
* migration ids" that can be read client-side before running the worker.
* Instead, `--migrate` runs `migrate status` followed by `migrate up` for
* each migration id discovered locally via `discoverMigrations`. The
* worker's `MigrationRunner` is idempotent — running `up` on an already-
* applied migration is a no-op — so this approach is safe.
*/
migrate?: boolean;
/** Admin bearer token for `--migrate` (falls back to `LUNORA_ADMIN_TOKEN`). */
migrateToken?: string;
/**
* Worker URL for `--migrate`. REQUIRED when `--migrate` is set — the deploy
* handler never captures the URL `wrangler deploy` published to, so there is
* no safe default; omitting it would silently target `http://localhost:8787`
* (the dev worker), applying the migration to local state instead of prod.
*/
migrateUrl?: string;
/**
* Confirm a production data migration triggered via `--migrate` (the
* `migrate up --prod` confirmation the standalone command requires). Without
* it a `--migrate --migrate-url <prod>` deploy refuses to run the migration.
*/
migrateYes?: boolean;
/**
* Emit the bundled worker to this directory via `wrangler deploy --outdir`
* (paired with `dryRun` by `lunora build`). Also writes esbuild metadata to
* `<outDir>/bundle-meta.json`. When unset, no artifact is written.
*/
outDir?: string;
/**
* Upload a preview version (`wrangler versions upload`) instead of a live
* `wrangler deploy`. Codegen + the drift gate + validation still run, but
* the post-deploy finalize (migrations, baseline re-bless, auto-link, the
* production summary) is skipped — a preview never shifts live traffic.
*/
preview?: boolean;
/** Railpack-availability probe injected in tests. Defaults to a real `railpack --version` + `BUILDKIT_HOST` check. */
railpackAvailable?: DockerProbe;
/** Confirm prompt for the missing-secret offer; injected in tests. Defaults to the TTY prompt. */
secretConfirm?: (message: string) => Promise<boolean>;
/** Remote-secret lister for the missing-secret offer; injected in tests. Defaults to `wrangler secret list`. */
secretLister?: (inputs: ListRemoteSecretsInputs) => Promise<ListRemoteSecretsResult>;
skipCodegen?: boolean;
spawner?: Spawner;
/**
* Fail the deploy when codegen reports an ERROR-level advisory. Same
* option `lunora codegen` exposes as `--no-strict-advisories`; defaults to
* CI detection (on in CI, off locally) so a legitimately-partial target
* can still be shipped interactively. Does NOT gate platform diagnostics
* (`platform_unsupported_feature` / `platform_unknown_target`), which
* always block — those mean the emitted `ctx.*` surface does not match
* what the target can serve, not merely a style nit.
*/
strictAdvisories?: boolean;
/**
* Deploy target. Falls back to `"target"` in `lunora.json`, then
* `"cloudflare"`, which selects the wrangler
* toolchain — i.e. today's behavior for every project. An unregistered name
* throws rather than falling back, so a typo can never ship the app to the
* wrong provider.
*/
target?: string;
/**
* Deploy to a temporary Cloudflare account (`wrangler deploy --temporary`).
* For unauthenticated use only: wrangler provisions a short-lived account +
* token, deploys, and prints a claim URL; the deployment stays live ~60
* minutes before the unclaimed account is deleted. Wrangler itself errors
* if credentials are already present (OAuth / `CLOUDFLARE_API_TOKEN` /
* global API key), so we pass the flag straight through without guarding.
*/
temporary?: boolean;
/** Re-bless the committed schema baseline with the current shape (accepts breaking drift). */
updateSchemaBaseline?: boolean;
}
/**
* What this run put where — the identity of the thing that was just deployed.
*
* Present on every run that reached (and completed) the wrangler invocation,
* including `--dry-run` and `--preview`, so a consumer can tell "nothing went
* live" from "went live" without inferring it from a missing `url`. A dry run
* publishes nothing and therefore never carries a `url`.
*
* No `versionId`: the pinned wrangler (4.114.0) has no structured deploy output
* and no flag that returns the version id — it only prints it in prose, and
* scraping a second value out of prose is exactly what this shouldn't do. The
* id is available from `lunora deployments list` after the fact.
*/
interface DeployedIdentity {
/** ISO-8601 stamp taken when the wrangler invocation returned. */
deployedAt: string;
/** True when `--dry-run` validated + bundled without publishing. */
dryRun: boolean;
/** The Cloudflare environment this run targeted, when `--env` named one. */
env?: string;
/** True when `--preview` uploaded a version instead of shifting live traffic. */
preview: boolean;
/** The URL wrangler reported publishing to; absent on a dry run, or when the output carried no URL. */
url?: string;
/** The Worker name from the project's wrangler config. */
workerName?: string;
}
interface DeployCommandResult {
code: number;
/** What was deployed and where — set once the wrangler invocation completed. */
deployment?: DeployedIdentity;
descriptor: SpawnDescriptor | undefined;
/** Set when the run aborted before reaching the wrangler invocation. */
error?: string;
/**
* The `--health-check` probe's verdict, when the flag was set and the probe
* ran. A red probe fails the command (`code` is non-zero) — but the deploy
* itself still succeeded, which is why the reason is reported separately
* from `error`.
*/
healthCheck?: {
error?: string;
ok: boolean;
url: string;
};
/**
* The `.dev.vars`-shaped filename (never a full path, never a value) a
* secret minted during this run was recorded into, when the missing-
* secret gate minted one — `.dev.vars` for the default environment, or a
* `.dev.vars.<env>` sibling for an explicit `--env`. `undefined` when
* nothing was minted this run.
*/
mintedSecretsFile?: string;
/** The schema-drift gate verdict, when it ran (skipped on `--skip-codegen`). */
schemaDrift?: {
blocked: boolean;
reason: string;
};
validation: {
problems: ReadonlyArray<string>;
wranglerPath: string | undefined;
};
}
/**
* Run a deploy, then (in `--format json` mode) serialize the structured
* {@link DeployCommandResult} to stdout. Human/progress logging is routed to
* stderr for json output so stdout carries only the single JSON document.
*/
declare const runDeployCommand: (options: DeployCommandOptions) => Promise<DeployCommandResult>;
/**
* Start the codegen watch loop and return a handle to stop it. Regenerates on
* startup, then on debounced changes under `lunora/` (ignoring writes to the
* `_generated/` output to avoid a feedback loop). If the platform can't do a
* recursive watch, it logs once and falls back to startup-only codegen.
*/
declare const startCodegenWatch: (options: CodegenWatcherOptions) => CodegenWatcherHandle;
interface CodegenWatcherOptions {
/** Which API spec(s) to emit. Defaults to codegen's `"openapi"` when omitted. */
apiSpec?: CodegenOptions["apiSpec"];
/** Debounce window for coalescing rapid edits. Defaults to 100ms. */
debounceMs?: number;
logger: Logger;
/** Override the lunora subdirectory name. Defaults to `"lunora"`. */
lunoraDirectory?: string;
/** Project root containing the `lunora/` directory. */
projectRoot: string;
/** Deploy target the emitted `ctx.*` surface is tailored to. Resolved by the caller; falls back to `"target"` in `lunora.json`, then `"cloudflare"`. */
target?: string;
}
interface CodegenWatcherHandle {
/** Stop watching and cancel any pending regeneration. */
close: () => void;
/**
* `true` when the platform supports recursive watch and the loop is active.
* `false` when `fs.watch({ recursive })` threw — startup-only codegen was run
* but schema edits will NOT auto-regenerate. Callers can surface this in the
* dev banner so the degraded state is visible beyond the single startup warning.
*/
watchAvailable: boolean;
}
/**
* Start the studio server and resolve once it is listening. Loads the static
* bundle + renders the host HTML once up front; serves them and proxies
* `/_lunora/*` (HTTP + WS) to the worker.
*/
declare const startStudioServer: (options: StudioServerOptions) => Promise<StudioServerHandle>;
interface StudioServerOptions {
/** Project root — `.dev.vars` is read from here for the admin token. */
cwd: string;
/** Loopback host to bind. Defaults to `127.0.0.1` (admin tooling stays local). */
host?: string;
/** One-time warning sink for a missing/unbuilt `@lunora/studio`. */
logger?: {
warnOnce?: (message: string) => void;
};
/** Port to listen on. */
port: number;
/** Origin of the `wrangler dev` worker, e.g. `http://localhost:8787`. */
workerOrigin: string;
}
interface StudioServerHandle {
/** Stop listening and release the port. */
close: () => Promise<void>;
/** The URL to open in a browser. */
url: string;
}
/**
* How the dev child runs. `wrangler` is the classic `lunora dev` stack (wrangler
* worker + embedded studio + codegen watch) for a standalone class-C project.
* `vite` is a project on `@lunora/vite`: the plugin already runs the worker,
* studio, and codegen inside the Vite dev server, so `lunora dev` runs the
* project's own dev script and gets out of the way — this also covers class-B
* frameworks whose own dev server runs the worker in `workerd` (Astro 6 +
* `@astrojs/cloudflare`, which embeds `@cloudflare/vite-plugin` in `astro dev`:
* SSR + `/_lunora/*` + `ShardDO` in one process, HMR intact). `framework-worker`
* is a class-B framework whose dev server CANNOT host the `ShardDO` Durable
* Object (SvelteKit / Nuxt: their adapters use wrangler's `getPlatformProxy()`,
* which runs an empty-script Miniflare and does not emulate internal DOs); there
* `lunora dev` runs the framework's own dev server (front door, HMR, and — via
* its `@lunora/vite` plugin — studio + codegen) AND a second `wrangler dev`
* sidecar that owns the real `ShardDO` in `workerd`, wired via the committed
* `wrangler.dev.jsonc`.
*/
type DevFlavor = "framework-worker" | "vite" | "wrangler";
/** A running worker child the orchestrator controls: send signals, await its exit. */
interface WorkerProcess {
/** Resolves with the worker's exit code (1 if it failed to start). */
exited: Promise<number>;
kill: (signal: NodeJS.Signals) => void;
}
/** Spawns the worker child. Injectable so tests drive the orchestration without a real process. */
type WorkerSpawner = (descriptor: SpawnDescriptor & {
tag: string;
}, logger: Logger) => WorkerProcess;
interface DevCommandOptions {
/** Which API spec(s) the codegen watcher emits. Defaults to codegen's `"openapi"` when omitted. */
apiSpec?: ApiSpec;
/** Disable the codegen watch loop. */
codegen?: boolean;
cwd?: string;
/** Injection seam for tests — defaults to the real `.dev.vars` scaffolder. */
ensureEnv?: typeof ensureDevVariables;
/** Injection seam for tests — defaults to the real `.dev.vars.example` package-aware scaffolder. */
ensureExample?: typeof ensureDevVarsExample;
/** Injection seam for tests — defaults to the real empty-secret/admin-token filler. */
fillSecrets?: typeof fillDevSecrets;
/** Injection seam for tests — defaults to the real free-port probe ({@link findAvailablePort}). */
findFreePort?: (preferred: number) => Promise<number>;
/** Dev flavor override (tests / callers that already detected it) — defaults to {@link detectDevFlavor}. */
flavor?: DevFlavor;
/** Injection seam for tests — defaults to the real IPv6-loopback probe ({@link hasIpv6Loopback}). */
hasIpv6Loopback?: () => boolean;
logger: Logger;
/** Injection seam for tests — defaults to the real remote-config materializer. */
materializeRemote?: typeof materializeRemoteWranglerConfig;
/** Studio server port. */
port?: number;
/** Proxy D1/KV/R2 bindings to the deployed worker during dev (`LUNORA_REMOTE=1` / `--remote`); DO shards stay local. */
remote?: boolean;
/** Injection seam for tests — defaults to the real codegen watcher. */
startCodegen?: typeof startCodegenWatch;
/** Injection seam for tests — defaults to the real studio server. */
startStudio?: typeof startStudioServer;
/** Injection seam for tests — defaults to spawning a real `wrangler dev`. */
startWorker?: WorkerSpawner;
/** Disable the embedded studio server. */
studio?: boolean;
/** Deploy target the emitted `ctx.*` surface is tailored to. Resolved by the caller; falls back to `"target"` in `lunora.json`, then `"cloudflare"`. */
target?: string;
/** Disable the `wrangler dev` spawn — an external task runner owns the worker. */
worker?: boolean;
/** `wrangler dev` port. */
workerPort?: number;
}
interface DevRemotePlan {
/** Short binding labels remoted (e.g. `"DB (D1)"`), for the banner. */
bindings: string[];
/**
* Removes the generated temp wrangler config when dev exits. Always present
* and idempotent — a no-op when remote mode is off or nothing was
* materialized. The dev loop calls it on every shutdown path.
*/
cleanup: () => void;
/** Whether remote mode was requested. */
enabled: boolean;
/** Why remote mode didn't take effect despite being requested, for logging. */
reason?: string;
}
interface DevCommandPlan {
codegenEnabled: boolean;
/** Which stack the child runs — see {@link DevFlavor}. */
flavor: DevFlavor;
/**
* One-line redirect hint printed when a meta-framework is detected on the
* wrangler flavor: without `@lunora/vite` in the dependencies the worker
* still runs *inside* the framework's dev server, so the user should run
* their framework dev script for the full app. `undefined` for the vite
* flavor (`lunora dev` already runs the project's dev script there) and
* for a standalone project. Purely informational: the wrangler spawn runs
* regardless.
*/
frameworkHint?: string;
/**
* True when `wrangler dev` was given `--ip 127.0.0.1` because the host has no
* IPv6 loopback (`::1`) — surfaced so the dev loop can note the rebind.
* Always `false` for the vite flavor (the plugin owns its own bind).
*/
ipv4LoopbackForced: boolean;
/** The remote-binding decision: which D1/KV/R2 bindings hit the deployed worker. */
remote: DevRemotePlan;
/**
* The `wrangler dev` sidecar for the `framework-worker` flavor (SvelteKit /
* Nuxt): a second child that owns the real `ShardDO` in `workerd`, wired via
* the committed `wrangler.dev.jsonc`. `undefined` for every other flavor —
* only the two-process class-B stack has a sidecar. When present, `wrangler`
* (above) is the framework's own dev server (the front door / HMR) and this
* is the Lunora realtime plane.
*/
sidecar?: SpawnDescriptor & {
tag: string;
};
studioEnabled: boolean;
studioPort: number;
/**
* Whether this process spawns `wrangler dev`.
*
* `--no-worker` turns it off so an external task runner (Turbo, Nx, vis, a
* Procfile) can own worker supervision while `lunora dev` still provides
* codegen-watch and Studio. Without it, `lunora dev` insisted on being the
* process root, which is what blocked running the Lunora worker as one node
* in a larger dev graph.
*/
workerEnabled: boolean;
workerOrigin: string;
workerPort: number;
/** The primary child `lunora dev` spawns: `wrangler dev` (wrangler flavor) or the framework/`vite dev` server (vite / framework-worker). */
wrangler: SpawnDescriptor & {
tag: string;
};
}
/**
* Plan `lunora dev`. Wrangler flavor: the worker runs via `wrangler dev` and
* nothing else as a child process. Vite flavor (`@lunora/vite` declared): the
* plugin already runs the worker inside the Vite dev server, so the one child
* is the project's own dev script (`vite dev`, `astro dev`, …) and every CLI
* sibling is disabled. Pure + synchronous so it's unit-testable.
*/
declare const planDevCommand: (options: DevCommandOptions) => DevCommandPlan;
/**
* Start codegen watch + the studio server, spawn `wrangler dev`, print the
* banner, and resolve when the worker exits or the user interrupts — tearing
* down the sibling servers either way. The three side-effecting pieces (worker,
* studio, codegen) are injectable so this is testable without real I/O.
*/
declare const runDevCommand: (options: DevCommandOptions) => Promise<{
code: number;
plan: DevCommandPlan;
}>;
type PackageManager = "pnpm" | "npm" | "yarn" | "bun";
/** True when `manager` is on PATH — probed by running `<manager> --version`. Injectable for tests. */
type PackageManagerProbe = (manager: PackageManager) => boolean;
/** Supported CI providers. */
type CiProvider = "github" | "gitlab";
/** The per-framework auth-UI registry items (`auth-ui` resolves to one of these). */
type AuthUiItem = "auth-ui-angular" | "auth-ui-react" | "auth-ui-solid" | "auth-ui-svelte" | "auth-ui-vue";
/** A registry item a feature can install. */
type FeatureItem = "auth" | "auth-auth0" | "auth-clerk" | AuthUiItem | "mail";
/** A single file the item scaffolds into the project. */
interface RegistryFile {
/** Source path inside the item dir (e.g. `schema.ts`). */
from: string;
/** Merge strategy. `create-or-skip` writes whole files; `schema-extension` AST-merges schema.ts. */
merge: "create-or-skip" | "schema-extension";
/** Destination relative to the project root (e.g. `lunora/ratelimit/index.ts`). */
to: string;
}
/** A wrangler.jsonc binding addition. `path` is the jsonc key path; `value` the value to set. */
interface RegistryBinding {
path: ReadonlyArray<string>;
value: unknown;
}
/**
* An environment variable an item needs. Scaffolded into `.dev.vars` (Workers'
* local-secrets file) on add — non-secrets get their `value`; secrets get an
* empty placeholder and a reminder to run `wrangler secret put` for production.
*/
interface RegistryEnvVariable {
/** Human note on what the variable is for. */
description?: string;
/** The variable name (e.g. `RESEND_API_KEY`). */
name: string;
/** Mark as a secret: never write a value, only a placeholder, and remind about prod. Defaults to `true` when no `value` is given. */
secret?: boolean;
/** A default/example value for non-secret vars. */
value?: string;
}
/** A re-export the item needs injected into the worker entry point (class-B/C only). */
interface EntrypointReexport {
/** Optional JS comment placed above the re-export line. */
comment?: string;
/** Module specifier (e.g. `"_generated/workflows"` → `export * from "./lunora/_generated/workflows"`). */
module: string;
}
/** The `registry.json` manifest shape. */
interface RegistryManifest {
/** wrangler.jsonc additions (best-effort structural edits). */
bindings?: ReadonlyArray<RegistryBinding>;
/** npm deps to add to the project package.json (name → version range). */
deps?: Readonly<Record<string, string>>;
description?: string;
/** npm devDependencies to add to the project package.json. */
devDependencies?: Readonly<Record<string, string>>;
/** Post-install guidance printed after the item is added (per-item next steps). */
docs?: string;
/** Worker-entry re-exports the item needs (class-B/C only). */
entrypointReexports?: ReadonlyArray<EntrypointReexport>;
/** Environment variables the item needs; scaffolded into `.dev.vars`. */
envVars?: ReadonlyArray<RegistryEnvVariable>;
files: ReadonlyArray<RegistryFile>;
name: string;
/** Other registry items this one depends on (resolved transitively, deps first). */
requires?: ReadonlyArray<string>;
/** Short human-readable label (distinct from the longer `description`). */
title?: string;
}
interface AddCommandOptions {
/** Bypass the `--source` safety gate (matches init). */
allowUnsafeSource?: boolean;
/** `registry build --check`: verify the index is current instead of rewriting it. */
check?: boolean;
/** Inject a confirmer for non-interactive callers / tests. */
confirm?: (prompt: string) => Promise<boolean>;
cwd?: string;
/** Preview the file-level changes (a content diff) and write nothing. */
diff?: boolean;
/** Print the plan and stop without writing anything. */
dryRun?: boolean;
/** Local registry root (offline / tests). Expects per-item subdirs, each with a `registry.json`. */
from?: string;
/** Emit a JSON snapshot of the plan/result. */
json?: boolean;
/** `--list`: enumerate available items instead of adding. */
list?: boolean;
logger: Logger;
/** Item names to add (positional args). */
names: ReadonlyArray<string>;
/** `registry build` output path for the generated catalog (defaults to the root's `index.json`). */
out?: string;
/** Force-overwrite existing files (take the incoming copy) instead of skipping/conflicting. */
overwrite?: boolean;
/** Override the git ref (branch, tag, or commit) items are fetched from (default: version-derived); appended to the `source` base when that is set. Ignored when `from` is set. */
ref?: string;
/** Override the remote registry source base (default gh:anolilab/lunora/registry). */
source?: string;
/**
* Customize each resolved manifest after it is loaded but before the plan is
* printed / reconciled — used to inject user-chosen values into otherwise
* static manifests (e.g. the R2 `bucket_name` the init storage prompt asks
* for). Applied to every item; return the manifest unchanged to leave it as-is.
*/
transformManifest?: (manifest: RegistryManifest) => RegistryManifest;
/** Skip the package.json mutation confirmation prompt. */
yes?: boolean;
}
interface AddCommandResult {
/** Bindings written to wrangler.jsonc. */
bindings: ReadonlyArray<string>;
code: number;
/** Deps added to package.json. */
deps: ReadonlyArray<string>;
/** Files skipped because they already existed. */
skipped: ReadonlyArray<string>;
/** Files written (absolute paths). */
written: ReadonlyArray<string>;
}
/**
* A feature offered in the post-scaffold multi-select. `auth`/`email` carry a
* sub-prompt or alias; every other value IS the registry item name applied
* directly (`storage` → the `storage` registry item, etc.).
*/
type StackFeature = "ai" | "auth" | "auth-ui" | "backup" | "browser" | "cloudflare-access" | "crons" | "email" | "flags" | "hyperdrive" | "payment" | "presence" | "queue" | "storage" | "workflow";
/** Customize a resolved manifest before it is written (e.g. inject the chosen R2 bucket name). */
type OfferTransformManifest = (manifest: RegistryManifest) => RegistryManifest;
/**
* One feature ready to apply: the registry item name(s), an optional manifest
* transform, and a short `label` (the feature value) shown on the combined
* progress line. Built up-front by the collectors so every prompt is answered
* before any apply runs.
*/
interface FeatureApply {
label: string;
names: ReadonlyArray<string>;
transformManifest?: OfferTransformManifest;
}
interface OfferDeps {
/**
* Apply the collected features into the new project in one batch — resolves
* `true` when every item succeeds. The CLI renders this as a single progress
* line whose label changes per feature; each plan's `transformManifest`
* customizes that item's manifest before it is written.
*/
applyAll: (plans: ReadonlyArray<FeatureApply>) => Promise<boolean>;
/** When `false`, skip all prompts and print the later-setup hint. */
interactive: boolean;
logger: Logger;
/** Multi-select among the stack features to add (TTY-backed in production). */
multiSelect: (message: string, options: ReadonlyArray<{
description?: string;
label: string;
value: StackFeature;
}>, settings?: {
defaults?: ReadonlyArray<StackFeature>;
}) => Promise<StackFeature[]>;
/**
* Features chosen non-interactively (the `--add` flag). When set, the
* multi-select and every sub-prompt are skipped — each feature is applied with
* its shipped defaults (base registry item, placeholder bindings).
*/
preselected?: ReadonlyArray<StackFeature>;
/** The new project's name — seeds smart defaults like the `project-uploads` bucket name. */
projectName: string;
/**
* Resolve which per-framework auth-UI item (`auth-ui-react|vue|…`) fits the
* scaffolded project. Injected by the CLI (detected from the template's deps);
* defaults to `auth-ui-react` when absent so this module stays pure/testable.
*/
resolveAuthUiItem?: () => string;
/** Single-select among the auth providers (TTY-backed in production). */
select: (message: string, options: ReadonlyArray<{
description?: string;
label: string;
value: FeatureItem;
}>, settings?: {
default?: FeatureItem;
}) => Promise<FeatureItem | undefined>;
/** Single-line text input (TTY-backed in production) — used for the storage bucket-name prompt. */
text: (message: string, settings?: {
default?: string;
placeholder?: string;
}) => Promise<string>;
}
/** One choice in the multi-select. */
interface LintToolOption {
description: string;
label: string;
value: LintTool;
}
interface LintToolOfferDeps {
/** Write the ignores for the chosen tools — `applyLintIgnores` in production. */
apply: (tools: ReadonlyArray<LintTool>) => LintIgnoreOutcome[];
/** Tools already detectable in the scaffolded project — pre-selected in the prompt. */
detected: ReadonlyArray<LintTool>;
/** False in CI / `--yes` / off a TTY: skip the prompt and configure whatever was detected. */
interactive: boolean;
logger: Logger;
multiSelect: (message: string, choices: ReadonlyArray<LintToolOption>, settings?: {
defaults?: ReadonlyArray<LintTool>;
}) => Promise<LintTool[]>;
}
type Template = "analog" | "astro" | "expo" | "next" | "nuxt" | "react-router" | "standalone" | "sveltekit" | "tanstack-start-react" | "tanstack-start-solid" | "vinext" | "vinext-pages";
interface InitCommandOptions {
/**
* Add features non-interactively after scaffolding (the `--add` flag): a
* comma-separated list of `ai | auth | backup | browser | cloudflare-access | crons | email | flags | hyperdrive | payment | presence | queue | storage | workflow`.
* Bypasses the interactive multi-select and sub-prompts —
* each named feature is applied with its shipped defaults.
*/
add?: string;
/**
* When true, accept `--source` values that don't start with `gh:` /
* `github:` / `https://` or that contain `..`. Defaults to false; the CLI
* gate exists to stop arbitrary filesystem / scheme sources from being
* pulled without the caller opting in.
*/
allowUnsafeSource?: boolean;
/** When set, also scaffold a CI deploy pipeline for the given provider. */
ci?: CiProvider;
cwd?: string;
/**
* Walk the whole flow — prompts, task list, next-steps, mascot — but make no
* changes: skip the template fetch/copy, the feature applies, the dependency
* install, and `git init`. Each skipped action logs a `would …` line instead.
*/
dryRun?: boolean;
/**
* Local directory containing the template subdirs (e.g. `vite/`,
* `standalone/`). When provided, skips the network fetch entirely.
* Useful for offline runs, the clean-machine smoke test, and unit tests.
*/
from?: string;
/**
* When true, configure Lunora into the CURRENT project (`cwd`) instead of
* scaffolding a new directory. Finds an existing `vite.config.*` and
* patches it via `patchViteConfig`, or creates a minimal one when absent.
* All other scaffold options (`name`, `templateType`, `source`, `from`)
* are ignored in this mode.
*/
inPlace?: boolean;
/**
* Inject the post-scaffold install offer's prompts (tests). When set, the
* offer runs regardless of TTY: `confirmInstall` drives the yes/no, and
* `selectManager` picks among the detected managers.
*/
installPrompt?: {
confirmInstall: () => Promise<boolean>;
selectManager: (managers: ReadonlyArray<PackageManager>) => Promise<PackageManager>;
};
/**
* Force the post-scaffold "add auth / email?" offer on (the `--interactive`
* flag). When omitted, the offer runs only when stdin is a TTY. `--yes`
* suppresses it regardless. Has no effect once {@link prompt} is injected.
*/
interactive?: boolean;
/**
* Test seam for the lint/formatter multi-select. Separate from {@link prompt}
* because that one is pinned to the feature-offer's value union — reusing it
* here would only typecheck through a cast.
*/
lintPrompt?: LintToolOfferDeps["multiSelect"];
logger: Logger;
name?: string;
/**
* Local directory holding create-vite bases (one `template-<id>/` subdir per
* framework). When set with `vite`, the overlay copies the base from disk
* instead of fetching `create-vite` over the network — offline mode + tests.
*/
overlayBaseFrom?: string;
/** Probe for which package managers are installed (tests). Defaults to a real `<pm> --version` check. */
packageManagerProbe?: PackageManagerProbe;
/**
* Inject the offer's prompts (tests). When set, the offer is treated as
* interactive regardless of TTY, and these drive the feature multi-select,
* the auth-provider sub-select, and the storage bucket-name text input.
*/
prompt?: Pick<OfferDeps, "multiSelect" | "select" | "text">;
/**
* Override the git ref (branch, tag, or commit) the default template source
* is fetched from. Takes precedence over the version-derived ref. Ignored
* when `source` or `from` is set.
*/
ref?: string;
/** Local registry root for the offer's `runAddCommand` (offline / tests). Mirrors `from` but for registry items. */
registryFrom?: string;
/** Override the remote registry source base for the offer (default `gh:anolilab/lunora/registry`). */
registrySource?: string;
/**
* Override the remote source giget downloads from. Default:
* `gh:anolilab/lunora/templates/<templateType>#<ref>`, where `<ref>` is
* the `ref` option when set, else derived from the CLI version (pre-release
* channels → their branch, stable → `main`). Tests typically use `from`
* instead to skip the network.
*/
source?: string;
/** Spawner for the post-scaffold dependency install (tests inject a recording stub). Defaults to a real subprocess. */
spawner?: Spawner;
templateType?: Template;
/**
* Scaffold via the **create-vite overlay** for this framework (`react`,
* `vue`, `solid`, `svelte`, `vanilla`) instead of a bespoke template: fetch
* the official create-vite base and apply the Lunora layer on top. Takes
* precedence over `templateType`.
*/
vite?: string;
/** Suppress the offer entirely (the `--yes` flag): scaffold only, print the later-setup hint. */
yes?: boolean;
}
interface InitCommandResult {
code: nu