UNPKG

@lunora/cli

Version:

The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands

1,127 lines 58 kB
import { CodegenOptions, Finding, SchemaIR } from '@lunora/codegen'; import '@visulima/cerebro'; import { ensureDevVariables, ensureDevVarsExample, fillDevSecrets, LintTool, LintIgnoreOutcome } from '@lunora/config'; import 'adm-zip'; 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"; 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 InitCommandR