UNPKG

@lunora/cli

Version:

The Lunora CLI: init, dev, deploy, codegen, run, reset, and migrate commands

1,182 lines (1,174 loc) 42.3 kB
import { spawnSync, spawn } from 'node:child_process'; import { existsSync, watch, readFileSync, statSync, openSync, readSync, closeSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { runCodegen } from '@lunora/codegen'; import { detectAgentRules, claimDevServerState, clearDevServerState, readDevServerState, readLiveDevServerState, detectFramework, readProjectDependencyNames, DEV_HANDOFF_ENV, isRecordedProcessCurrent, DEV_LOG_FILE, DEV_LOG_FILE_ENV, DEV_DAEMON_ENV, updateDevServerState, detectAiAgent, resolveRemoteEnabled, readProjectRemotePreference, inferLunoraBindings, packageNamesFromBindings, ensureDevVarsExample, ensureDevVariables, isInteractive, DEV_VARS_FILE, DEV_VARS_EXAMPLE_FILE, fillDevSecrets, claimAgentRulesHint, AGENT_RULES_HINT, findWranglerFile, readWranglerJsonc, discoverContainerInfo, streamContainerLogs, materializeRemoteWranglerConfig, formatLunoraEvent } from '@lunora/config'; import { p as parseApiSpec } from '../packem_shared/api-spec-Bx0iKbxA.mjs'; import { r as renderCodegenFailure } from '../packem_shared/codegen-error-DJG-ghs_.mjs'; import { d as defineHandler } from '../packem_shared/command-lYnl4QyF.mjs'; import { d as detectPackageManager, e as execArgsFor, r as runScriptCommand } from '../packem_shared/detect-package-manager-v4hHpQd0.mjs'; import { createServer, connect } from 'node:net'; import { forceJsonLogging } from '../packem_shared/createLogger-CIWSHrTL.mjs'; import { networkInterfaces } from 'node:os'; import { spawnShellCompat } from '../packem_shared/createRecordingSpawner-WuSn20kb.mjs'; import { createServer as createServer$1, request } from 'node:http'; import { loadStudioAssets, studioAssetsStamp, renderStudioHtml, resolveAdminToken, SCHEMA_EDIT_ENDPOINT, POLICY_SCAFFOLD_ENDPOINT, SEED_ENDPOINT, serveJsonHandler, isStandaloneModulePath, readStandaloneAsset, assetContentType, handleSchemaEditRequest, handlePolicyScaffoldRequest, handleSeedRequest } from '@lunora/config/studio-host'; import { c as createTuiConfirm } from '../packem_shared/tui-prompts-BjEN8XgP.mjs'; import { p as printJson } from '../packem_shared/output-format-B4642rjE.mjs'; const DEFAULT_DEBOUNCE_MS = 100; const PATH_SEGMENT_SEPARATOR = /[/\\]/u; const runOnce = (projectRoot, lunoraDirectory, apiSpec, logger, reason) => { try { runCodegen({ apiSpec, lunoraDirectory, projectRoot }); logger.success(`codegen: wrote ${lunoraDirectory}/_generated (${reason})`); } catch (error) { logger.error(renderCodegenFailure(error, reason)); } }; const startCodegenWatch = (options) => { const lunoraDirectory = options.lunoraDirectory ?? "lunora"; const watchDirectory = join(options.projectRoot, lunoraDirectory); const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS; const { apiSpec } = options; runOnce(options.projectRoot, lunoraDirectory, apiSpec, options.logger, "startup"); let timer; let watcher; if (!existsSync(watchDirectory)) { options.logger.warn( `codegen watch unavailable (no such directory: ${watchDirectory}) — schema edits will NOT auto-regenerate. Run \`lunora codegen\` manually after each edit.` ); return { close: () => { }, watchAvailable: false }; } try { watcher = watch(watchDirectory, { recursive: true }, (_event, filename) => { if (typeof filename === "string" && filename.split(PATH_SEGMENT_SEPARATOR).includes("_generated")) { return; } if (timer) { clearTimeout(timer); } timer = setTimeout(runOnce, debounceMs, options.projectRoot, lunoraDirectory, apiSpec, options.logger, `change: ${filename ?? "?"}`); }); } catch (error) { options.logger.warn( `codegen watch unavailable (${error instanceof Error ? error.message : String(error)}) — schema edits will NOT auto-regenerate. Run \`lunora codegen\` manually after each edit.` ); return { close: () => { }, watchAvailable: false }; } const liveWatcher = watcher; return { close: () => { if (timer) { clearTimeout(timer); } liveWatcher.close(); }, watchAvailable: true }; }; const DEFAULT_PROBE_ATTEMPTS = 64; const LOOPBACK_HOST = "127.0.0.1"; const MAX_PORT = 65535; const isPortFree = (port, host = LOOPBACK_HOST) => new Promise((resolve) => { const server = createServer(); server.once("error", () => { resolve(false); }); server.once("listening", () => { server.close(() => { resolve(true); }); }); server.listen(port, host); }); const findAvailablePort = async (preferred, host = LOOPBACK_HOST, attempts = DEFAULT_PROBE_ATTEMPTS) => { for (let port = preferred; port < preferred + attempts && port <= MAX_PORT; port += 1) { if (await isPortFree(port, host)) { return port; } } return preferred; }; const hasIpv6Loopback = (readInterfaces = networkInterfaces) => Object.values(readInterfaces()).some((addresses) => addresses?.some((address) => address.internal && address.address === "::1")); const PROXY_PREFIX = "/_lunora"; const pathnameOf = (url) => { const queryIndex = url.indexOf("?"); return queryIndex === -1 ? url : url.slice(0, queryIndex); }; const LOOPBACK_HOST_NAMES = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "[::1]", "localhost"]); const isLoopbackHost = (hostHeader) => { if (hostHeader === void 0 || hostHeader === "") { return false; } const withoutPort = hostHeader.startsWith("[") ? hostHeader.slice(0, hostHeader.indexOf("]") + 1) : hostHeader.split(":")[0] ?? hostHeader; return LOOPBACK_HOST_NAMES.has(withoutPort); }; const proxyHttp = (request$1, response, worker) => { const upstream = request( { headers: { ...request$1.headers, host: worker.host }, hostname: worker.hostname, method: request$1.method, path: request$1.url, port: worker.port }, (upstreamResponse) => { response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); upstreamResponse.pipe(response); } ); upstream.on("error", (error) => { response.statusCode = 502; response.end(`lunora dev: worker unreachable (${error.message})`); }); request$1.pipe(upstream); }; const UPGRADE_CONNECT_TIMEOUT_MS = 5e3; const proxyUpgrade = (request, clientSocket, head, worker) => { const upstream = connect({ host: worker.hostname, port: Number(worker.port) }, () => { upstream.setTimeout(0); const lines = [`${request.method ?? "GET"} ${request.url ?? "/"} HTTP/1.1`]; for (const [key, value] of Object.entries(request.headers)) { if (key === "host") { continue; } for (const item of Array.isArray(value) ? value : [value]) { if (item !== void 0) { lines.push(`${key}: ${item}`); } } } lines.push(`host: ${worker.host}`, "", ""); upstream.write(lines.join("\r\n")); if (head.length > 0) { upstream.write(head); } upstream.pipe(clientSocket); clientSocket.pipe(upstream); }); upstream.setTimeout(UPGRADE_CONNECT_TIMEOUT_MS, () => upstream.destroy()); upstream.on("error", () => clientSocket.destroy()); clientSocket.on("error", () => upstream.destroy()); }; const startStudioServer = async (options) => { const host = options.host ?? "127.0.0.1"; const isLoopback = host === "127.0.0.1" || host === "localhost" || host === "::1"; const worker = new URL(options.workerOrigin); let assets = loadStudioAssets(options.logger, import.meta.url); let assetsStamp = studioAssetsStamp(import.meta.url); const html = renderStudioHtml({ // The admin token grants full read/write over the local worker. Only embed // it for a loopback bind: a non-loopback (LAN/0.0.0.0) bind would otherwise // hand the token to any network client that GETs `/`. adminToken: isLoopback ? resolveAdminToken(options.cwd) : void 0, basePath: "/", dataEditable: isLoopback, rulesInstalled: detectAgentRules(options.cwd).installed, runAsIdentity: isLoopback, schemaEditable: isLoopback, scriptSrc: "/studio.js", styleHref: "/styles.css" }); const sendAsset = (response, body, contentType) => { response.statusCode = 200; response.setHeader("Content-Type", contentType); response.end(body); }; const serveLoopbackOnly = (request, response, handle, deniedMessage) => { if (isLoopback) { serveJsonHandler(request, response, handle, options.cwd); return; } response.statusCode = 403; response.setHeader("Content-Type", "text/plain"); response.end(deniedMessage); }; const serveStaticAsset = (pathname, response) => { const isStyle = pathname === "/styles.css"; const isModule = isStandaloneModulePath(pathname); if (!isStyle && !isModule) { return false; } const stamp = studioAssetsStamp(import.meta.url); if (stamp !== assetsStamp) { assets = loadStudioAssets(options.logger, import.meta.url); assetsStamp = stamp; } if (assets === void 0) { response.statusCode = 501; response.setHeader("Content-Type", "text/plain"); response.end("Lunora studio assets not found — install and build @lunora/studio."); return true; } if (isStyle) { sendAsset(response, assets.styles, "text/css; charset=utf-8"); return true; } const fileName = pathname.slice(pathname.lastIndexOf("/") + 1); const bytes = readStandaloneAsset(fileName, import.meta.url); if (bytes === void 0) { response.statusCode = 404; response.setHeader("Content-Type", "text/plain"); response.end("Not found"); return true; } sendAsset(response, bytes, assetContentType(fileName)); return true; }; const document = Buffer.from(html); const server = createServer$1((request, response) => { const pathname = pathnameOf(request.url ?? "/"); if (isLoopback && !isLoopbackHost(request.headers.host)) { response.statusCode = 403; response.setHeader("Content-Type", "text/plain"); response.end("Lunora studio: refusing a request whose Host is not a loopback literal (DNS-rebinding guard)."); return; } if (pathname.startsWith(PROXY_PREFIX)) { if (!isLoopback) { response.statusCode = 403; response.setHeader("Content-Type", "text/plain"); response.end("Lunora studio: the worker admin proxy is only available on a loopback bind."); return; } proxyHttp(request, response, worker); return; } if (pathname === SCHEMA_EDIT_ENDPOINT) { serveLoopbackOnly(request, response, handleSchemaEditRequest, "Lunora schema editing is only available on loopback hosts in dev."); return; } if (pathname === POLICY_SCAFFOLD_ENDPOINT) { serveLoopbackOnly(request, response, handlePolicyScaffoldRequest, "Lunora policy scaffolding is only available on loopback hosts in dev."); return; } if (pathname === SEED_ENDPOINT) { serveLoopbackOnly(request, response, handleSeedRequest, "Lunora data seeding is only available on loopback hosts in dev."); return; } if (serveStaticAsset(pathname, response)) { return; } sendAsset(response, document, "text/html; charset=utf-8"); }); server.on("upgrade", (request, socket, head) => { if (!isLoopback || !isLoopbackHost(request.headers.host)) { socket.destroy(); return; } if (pathnameOf(request.url ?? "").startsWith(PROXY_PREFIX)) { proxyUpgrade(request, socket, head, worker); } else { socket.destroy(); } }); await new Promise((resolve, reject) => { server.once("error", (error) => { if (error.code === "EADDRINUSE") { reject( new Error(`studio port ${String(options.port)} is already in use — pass a different port with --port, or stop the process using it`, { cause: error }) ); } else { reject(error); } }); server.listen(options.port, host, () => { resolve(); }); }); return { close: () => new Promise((resolve) => { server.close(() => { resolve(); }); }), url: `http://${host === "0.0.0.0" || host === "::" ? "localhost" : host}:${String(options.port)}` }; }; const DEFAULT_READY_TIMEOUT_MS = 12e4; const POLL_INTERVAL_MS = 250; const STOP_GRACE_MS = 1e4; const DEFAULT_LOG_LINES = 100; const FAILURE_LOG_TAIL_LINES = 40; const LOG_TAIL_MAX_BYTES = 256 * 1024; const READY_TIMEOUT_ENV = "LUNORA_DEV_READY_TIMEOUT_MS"; const SIDECAR_FRAMEWORKS = /* @__PURE__ */ new Set(["nuxt", "sveltekit"]); const detectDevFlavor = (cwd) => { if (SIDECAR_FRAMEWORKS.has(detectFramework(cwd).framework)) { return "framework-worker"; } return readProjectDependencyNames(cwd).has("@lunora/vite") ? "vite" : "wrangler"; }; const viteDevCommand = (cwd) => { const manager = detectPackageManager(cwd); let script; try { const raw = readFileSync(join(cwd, "package.json"), "utf8"); script = JSON.parse(raw).scripts?.dev; } catch { } if (script === void 0 || script.trim() === "" || script.includes("lunora")) { const exec = execArgsFor(manager, "vite", ["dev"]); return { args: exec.args, command: exec.command }; } return { args: ["run", "dev"], command: manager }; }; const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }); const defaultProbe = async (origin) => { try { await fetch(new URL("/_lunora/status", origin), { signal: AbortSignal.timeout(1e3) }); return true; } catch { return false; } }; const defaultDetachedSpawner = (descriptor) => { mkdirSync(dirname(descriptor.logPath), { recursive: true }); const logFd = openSync(descriptor.logPath, "w", 384); let child; const exec = spawnShellCompat(descriptor.command, descriptor.args); try { child = spawn(exec.command, exec.args, { cwd: descriptor.cwd, detached: true, env: descriptor.env, shell: exec.shell, stdio: ["ignore", logFd, logFd], windowsHide: true }); } finally { closeSync(logFd); } child.unref(); return { exited: new Promise((resolve) => { child.on("error", () => { resolve(1); }); child.on("exit", (code, signal) => { resolve(code ?? (signal ? 1 : 0)); }); }), pid: child.pid }; }; const readLogWindow = (path) => { try { const { size } = statSync(path); if (size <= LOG_TAIL_MAX_BYTES) { return readFileSync(path, "utf8"); } const fd = openSync(path, "r"); try { const buffer = Buffer.alloc(LOG_TAIL_MAX_BYTES); const read = readSync(fd, buffer, 0, LOG_TAIL_MAX_BYTES, size - LOG_TAIL_MAX_BYTES); const text = buffer.toString("utf8", 0, read); return text.slice(text.indexOf("\n") + 1); } finally { closeSync(fd); } } catch { return void 0; } }; const readLogTail = (path, count) => { const text = readLogWindow(path); if (text === void 0) { return []; } const lines = text.split("\n"); if (lines.at(-1) === "") { lines.pop(); } return count > 0 ? lines.slice(-count) : lines; }; const resolveLogPath = (cwd, state) => state?.logFile ?? join(cwd, DEV_LOG_FILE); const printLifecycleHints = (logger) => { logger.info(" Stop: lunora dev stop"); logger.info(" Status: lunora dev status"); logger.info(" Logs: lunora dev logs"); }; const reportExistingServer = (logger, existing) => { logger.warn(`Dev server already running at ${existing.url} (pid ${String(existing.pid)})`); printLifecycleHints(logger); }; const printBackgroundBanner = (logger, state) => { logger.success(`Dev server running at ${state.url} (pid ${String(state.pid)})`); if (state.studioUrl !== void 0) { logger.info(` Studio: ${state.studioUrl}`); } printLifecycleHints(logger); }; const waitUntilReady = async (parameters) => { const { child, cwd, deadline, pollIntervalMs, probe } = parameters; const exited = child.exited.then((code) => { return { exitCode: code }; }); while (Date.now() < deadline) { const state = readLiveDevServerState(cwd); if (state !== void 0 && state.pid !== process.pid && await probe(state.url)) { return { state, status: "ready" }; } const raced = await Promise.race([exited, sleep(pollIntervalMs)]); if (raced !== void 0) { return { ...raced, status: "exited" }; } } return { status: "timeout" }; }; const runDevBackground = async (options) => { const { cwd, logger } = options; const logPath = resolveLogPath(cwd, void 0); const environmentTimeout = Number(process.env[READY_TIMEOUT_ENV] ?? ""); const timeout = options.readyTimeoutMs ?? (Number.isFinite(environmentTimeout) && environmentTimeout > 0 ? environmentTimeout : DEFAULT_READY_TIMEOUT_MS); const child = (options.spawnDetached ?? defaultDetachedSpawner)({ args: options.command.args, command: options.command.command, cwd, env: { ...process.env, ...options.env, [DEV_DAEMON_ENV]: "1", [DEV_LOG_FILE_ENV]: logPath, // The capture log is read back by `lunora dev logs` (and often an // agent) — ANSI colour codes would only garble it. ...process.env.FORCE_COLOR === void 0 ? { NO_COLOR: "1" } : {}, ...options.json ? { LUNORA_LOG_JSON: "1" } : {} }, logPath }); const outcome = await waitUntilReady({ child, cwd, deadline: Date.now() + timeout, pollIntervalMs: options.pollIntervalMs ?? POLL_INTERVAL_MS, probe: options.probe ?? defaultProbe }); if (outcome.status === "ready") { printBackgroundBanner(logger, outcome.state); return { code: 0 }; } if (outcome.status === "exited") { logger.error(`dev server exited before becoming ready (exit code ${String(outcome.exitCode)}). Last output:`); for (const line of readLogTail(logPath, FAILURE_LOG_TAIL_LINES)) { logger.error(` ${line}`); } clearDevServerState(cwd, child.pid ?? process.pid); return { code: outcome.exitCode === 0 ? 1 : outcome.exitCode }; } if (child.pid !== void 0) { const current = readDevServerState(cwd); if (current?.pid === process.pid) { updateDevServerState(cwd, { logFile: logPath, pid: child.pid }); } } const pidHint = child.pid === void 0 ? "" : ` (pid ${String(child.pid)})`; logger.warn( `dev server did not confirm ready within ${String(Math.round(timeout / 1e3))}s — it may still be compiling${pidHint}. Check \`lunora dev status\` and \`lunora dev logs\`; \`lunora dev stop\` shuts it down.` ); return { code: 1 }; }; const daemonArguments = (options, remote) => { const args = ["dev"]; if (options.apiSpec !== void 0) { args.push("--api-spec", options.apiSpec); } if (options.port !== void 0) { args.push("--port", String(options.port)); } if (options.workerPort !== void 0) { args.push("--worker-port", String(options.workerPort)); } if (options.codegen === false) { args.push("--no-codegen"); } if (options.studio === false) { args.push("--no-studio"); } if (remote) { args.push("--remote"); } return args; }; const startBackground = async (context) => { const { cwd, jsonLogs, logger, options, remote } = context; const run = context.run ?? runDevBackground; const flavor = detectDevFlavor(cwd); const url = flavor === "wrangler" ? `http://localhost:${String(options.workerPort ?? 8787)}` : "http://localhost:5173"; const claim = claimDevServerState(cwd, { background: true, mode: "cli", pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString(), url }); if (!claim.ok) { if (claim.existing !== void 0) { reportExistingServer(logger, claim.existing); } return { code: 0 }; } const handoff = { [DEV_HANDOFF_ENV]: String(process.pid) }; try { if (flavor === "vite") { return await run({ command: viteDevCommand(cwd), cwd, env: { ...remote ? { LUNORA_REMOTE: "1" } : {}, ...handoff }, json: jsonLogs, logger }); } return await run({ command: { args: [process.argv[1] ?? "lunora", ...daemonArguments(options, remote)], command: process.execPath }, cwd, env: handoff, json: jsonLogs, logger }); } finally { clearDevServerState(cwd, process.pid); } }; const processGroupId = (pid) => { try { const result = spawnSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" }); const pgid = Number.parseInt(result.stdout.trim(), 10); return Number.isInteger(pgid) && pgid > 0 ? pgid : void 0; } catch { return void 0; } }; const forceKillRecordedServer = (state, signal, platform = process.platform, spawnSyncImpl = spawnSync) => { if (platform === "win32") { try { spawnSyncImpl("taskkill", ["/pid", String(state.pid), "/T", "/F"], { stdio: "ignore" }); } catch { } return; } if (state.background === true) { try { signal(-(processGroupId(state.pid) ?? state.pid), "SIGKILL"); return; } catch { } } try { signal(state.pid, "SIGKILL"); } catch { } }; const reportSurvivedForceKill = (state, options) => { if (options.json) { printJson({ pid: state.pid, stopped: false }); } else { options.logger.error( `dev server (pid ${String(state.pid)}) survived the force-kill — record kept; retry \`lunora dev stop\` or kill the process manually.` ); } return { code: 1 }; }; const runDevStop = async (options) => { const { cwd, logger } = options; const state = readDevServerState(cwd); const alive = options.alive ?? ((pid) => pid === state?.pid && isRecordedProcessCurrent(state)); const signal = options.signal ?? ((pid, sig) => { process.kill(pid, sig); }); const pollInterval = options.pollIntervalMs ?? POLL_INTERVAL_MS; const grace = options.stopGraceMs ?? STOP_GRACE_MS; const platform = options.platform ?? process.platform; const spawnSyncImpl = options.spawnSyncImpl ?? spawnSync; if (state === void 0 || !alive(state.pid)) { if (state !== void 0) { clearDevServerState(cwd, state.pid); } if (options.json) { printJson({ running: false, stopped: false }); } else { logger.info("No dev server running."); } return { code: 0 }; } try { signal(state.pid, "SIGTERM"); } catch { } const deadline = Date.now() + grace; while (alive(state.pid) && Date.now() < deadline) { await sleep(pollInterval); } if (alive(state.pid)) { forceKillRecordedServer(state, signal, platform, spawnSyncImpl); await sleep(pollInterval); if (alive(state.pid)) { return reportSurvivedForceKill(state, options); } } clearDevServerState(cwd, state.pid); if (options.json) { printJson({ pid: state.pid, stopped: true }); } else { logger.success(`Stopped dev server (pid ${String(state.pid)}).`); } return { code: 0 }; }; const runDevStatus = (options) => { const { cwd, logger } = options; const state = readLiveDevServerState(cwd); if (state === void 0) { if (options.json) { printJson({ running: false }); } else { logger.info("No dev server running."); } return { code: 0 }; } const now = options.now ?? Date.now; const startedAtMs = state.startedAt === void 0 ? Number.NaN : Date.parse(state.startedAt); const uptimeSeconds = Number.isFinite(startedAtMs) ? Math.max(0, Math.round((now() - startedAtMs) / 1e3)) : void 0; if (options.json) { printJson({ background: state.background === true, logFile: state.logFile, mode: state.mode, pid: state.pid, running: true, startedAt: state.startedAt, studioUrl: state.studioUrl, uptimeSeconds, url: state.url }); return { code: 0 }; } const details = [ `pid ${String(state.pid)}`, ...uptimeSeconds === void 0 ? [] : [`uptime ${String(uptimeSeconds)}s`], state.background === true ? "background" : "foreground" ]; logger.success(`Dev server running at ${state.url} (${details.join(", ")})`); if (state.studioUrl !== void 0) { logger.info(` Studio: ${state.studioUrl}`); } if (state.logFile !== void 0) { logger.info(` Logs: lunora dev logs (${state.logFile})`); } return { code: 0 }; }; const runDevLogs = (options) => { const { cwd, logger } = options; const state = readDevServerState(cwd); const logPath = resolveLogPath(cwd, state); if (!existsSync(logPath)) { logger.info("No dev server logs found — output is captured when the server runs in background mode (`lunora dev --background`)."); return { code: 0 }; } const tail = readLogTail(logPath, options.lines ?? DEFAULT_LOG_LINES); if (tail.length > 0) { process.stdout.write(`${tail.join("\n")} `); } return { code: 0 }; }; const runLifecycleSubcommand = (parameters) => { const { cwd, json, lines, logger, subcommand } = parameters; if (subcommand === "stop") { return runDevStop({ cwd, json, logger }); } if (subcommand === "status") { return runDevStatus({ cwd, json, logger }); } if (subcommand === "logs") { return runDevLogs({ cwd, lines, logger }); } if (subcommand !== void 0) { logger.error(`dev: unknown subcommand "${subcommand}" — expected stop | status | logs (or no subcommand to start the dev server)`); return { code: 1 }; } return void 0; }; const DEV_WRANGLER_CONFIG = "wrangler.dev.jsonc"; const DEFAULT_STUDIO_PORT = 6173; const DEFAULT_WORKER_PORT = 8787; const DEFAULT_VITE_PORT = 5173; const SIGINT_GRACE_MS = 5e3; const resolveRemotePlan = (options, cwd) => { const noopCleanup = () => { }; if (!options.remote) { return { args: [], plan: { bindings: [], cleanup: noopCleanup, enabled: false } }; } const materialize = options.materializeRemote ?? materializeRemoteWranglerConfig; const result = materialize({ enabled: true, projectRoot: cwd }); const bindings = result.remoteBindings.map((binding) => `${binding.binding} (${binding.kind})`); const { cleanup } = result; if (result.configPath === void 0) { return { args: [], plan: { bindings, cleanup, enabled: true, reason: result.reason } }; } return { args: ["--config", result.configPath], plan: { bindings, cleanup, enabled: true } }; }; const readDevIp = (wranglerPath) => readWranglerJsonc(wranglerPath).parsed?.dev?.ip; const resolveLoopbackArgs = (cwd, hasLoopback, sidecarConfigFile) => { if (sidecarConfigFile !== void 0) { const sidecarConfigPath = join(cwd, sidecarConfigFile); if (existsSync(sidecarConfigPath) && readDevIp(sidecarConfigPath) !== void 0) { return []; } } const wranglerPath = findWranglerFile(cwd); if (wranglerPath !== void 0 && readDevIp(wranglerPath) !== void 0) { return []; } return hasLoopback() ? [] : ["--ip", "127.0.0.1"]; }; const resolveWorkerPort = async (options, cwd) => { if (options.workerPort !== void 0) { return options.workerPort; } const wranglerPath = findWranglerFile(cwd); if (wranglerPath !== void 0) { const { parsed } = readWranglerJsonc(wranglerPath); if (typeof parsed?.dev?.port === "number") { return parsed.dev.port; } } return (options.findFreePort ?? findAvailablePort)(DEFAULT_WORKER_PORT); }; const planDevCommand = (options) => { const cwd = options.cwd ?? process.cwd(); const manager = detectPackageManager(cwd); const flavor = options.flavor ?? detectDevFlavor(cwd); if (flavor === "vite" || flavor === "framework-worker") { const exec2 = viteDevCommand(cwd); let sidecar; if (flavor === "framework-worker") { const loopbackArgs2 = resolveLoopbackArgs(cwd, options.hasIpv6Loopback ?? hasIpv6Loopback, DEV_WRANGLER_CONFIG); const sidecarExec = execArgsFor(manager, "wrangler", ["dev", "--config", DEV_WRANGLER_CONFIG, ...loopbackArgs2, "--var", "WORKER_ENV:development"]); sidecar = { args: sidecarExec.args, command: sidecarExec.command, cwd, tag: "worker" }; } return { codegenEnabled: false, flavor, ipv4LoopbackForced: false, remote: { bindings: [], cleanup: () => { }, enabled: options.remote === true }, ...sidecar ? { sidecar } : {}, studioEnabled: false, studioPort: options.port ?? DEFAULT_STUDIO_PORT, workerOrigin: `http://localhost:${String(DEFAULT_VITE_PORT)}`, workerPort: DEFAULT_VITE_PORT, wrangler: { args: exec2.args, command: exec2.command, cwd, ...options.remote === true ? { env: { LUNORA_REMOTE: "1" } } : {}, tag: "vite" } }; } const detection = detectFramework(cwd); const frameworkHint = detection.framework === "none" ? void 0 : `this project uses ${detection.framework} — the worker runs inside Vite there. run \`${runScriptCommand(manager, "dev")}\` for the full app (frontend + HMR); \`lunora dev\` starts only the worker.`; const workerPort = options.workerPort ?? DEFAULT_WORKER_PORT; const remote = resolveRemotePlan(options, cwd); const loopbackArgs = resolveLoopbackArgs(cwd, options.hasIpv6Loopback ?? hasIpv6Loopback); const exec = execArgsFor(manager, "wrangler", ["dev", "--port", String(workerPort), ...loopbackArgs, "--var", "WORKER_ENV:development", ...remote.args]); return { codegenEnabled: options.codegen !== false, flavor, frameworkHint, ipv4LoopbackForced: loopbackArgs.length > 0, remote: remote.plan, studioEnabled: options.studio !== false, studioPort: options.port ?? DEFAULT_STUDIO_PORT, workerOrigin: `http://localhost:${String(workerPort)}`, workerPort, wrangler: { args: exec.args, command: exec.command, cwd, tag: "wrangler" } }; }; const emitChildLine = (line, tag, kind, logger) => { if (line.length === 0) { return; } const formatted = formatLunoraEvent(line); if (formatted) { logger[formatted.level](`[lunora] ${formatted.text}`); return; } const prefixed = `[${tag}] ${line}`; if (kind === "stderr") { logger.warn(prefixed); } else { logger.info(prefixed); } }; const pipeChildOutput = (child, tag, logger) => { const pumpStream = (stream, kind) => { if (!stream) { return; } let buffer = ""; stream.on("data", (chunk) => { buffer += chunk.toString("utf8"); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { emitChildLine(line.trimEnd(), tag, kind, logger); } }); stream.on("end", () => { emitChildLine(buffer.trimEnd(), tag, kind, logger); buffer = ""; }); }; pumpStream(child.stdout, "stdout"); pumpStream(child.stderr, "stderr"); }; const defaultWorkerSpawner = (descriptor, logger) => { const exec = spawnShellCompat(descriptor.command, descriptor.args); const child = spawn(exec.command, exec.args, { cwd: descriptor.cwd ?? process.cwd(), env: descriptor.env ? { ...process.env, ...descriptor.env } : process.env, shell: exec.shell, stdio: ["inherit", "pipe", "pipe"] }); pipeChildOutput(child, descriptor.tag, logger); return { exited: new Promise((resolve) => { child.on("error", (error) => { logger.error(`[${descriptor.tag}] failed to start: ${error.message}`); resolve(1); }); child.on("exit", (code) => { resolve(code ?? 0); }); }), kill: (signal) => { try { child.kill(signal); } catch { } } }; }; const printBanner = (logger, plan, studioUrl) => { logger.info(""); logger.success("Lunora dev"); logger.info(` ➜ Worker: ${plan.workerOrigin}`); if (studioUrl !== void 0) { logger.info(` ➜ Studio: ${studioUrl}`); } if (plan.codegenEnabled) { logger.info(" ➜ Codegen: watching lunora/"); } if (plan.remote.enabled) { if (plan.remote.bindings.length > 0) { logger.info(` ➜ Remote: ${plan.remote.bindings.join(", ")} → deployed worker`); } else { logger.warn(` ➜ Remote: requested but inactive (${plan.remote.reason ?? "no eligible bindings"}) — running fully local`); } } logger.info(""); }; const printAgentRulesHint = (logger, cwd) => { if (detectAgentRules(cwd).installed || !claimAgentRulesHint()) { return; } logger.info(` ⓘ ${AGENT_RULES_HINT}`); logger.info(""); }; const startContainerLogStreaming = (cwd, logger) => { if (process.env.LUNORA_CONTAINER_LOGS === "0") { return void 0; } const discovery = discoverContainerInfo(cwd, "lunora"); const containers = discovery.containers.map((container) => { return { className: container.className, exportName: container.exportName }; }); if (containers.length === 0) { return void 0; } return streamContainerLogs({ containers, onLine: (line) => { const tagged = `[container:${line.name}] ${line.text}`; if (line.level === "error") { logger.warn(tagged); } else { logger.info(tagged); } }, onUnavailable: (message) => { logger.warn(`[container] Docker engine unreachable — container logs unavailable (${message})`); } }); }; const teardown = async (handles) => { handles.codegen?.close(); handles.containerLogs?.close(); await handles.studio?.close().catch(() => void 0); try { handles.remoteCleanup?.(); } catch { } }; const offerDevVariablesScaffold = async (options, cwd) => { try { const bindings = await inferLunoraBindings({ projectRoot: cwd }); const packageNames = packageNamesFromBindings(bindings); const addedKeys = (options.ensureExample ?? ensureDevVarsExample)(cwd, packageNames); if (addedKeys.length > 0) { options.logger.info(`Updated .dev.vars.example with secrets for: ${packageNames.join(", ")} (${addedKeys.join(", ")})`); } } catch { } const result = await (options.ensureEnv ?? ensureDevVariables)({ confirm: createTuiConfirm(), cwd, info: (message) => { options.logger.info(message); } }); if (result.status === "declined" && !isInteractive()) { options.logger.info( `hint: ${DEV_VARS_FILE} was not scaffolded (non-interactive run). Copy ${DEV_VARS_EXAMPLE_FILE}${DEV_VARS_FILE} and fill in secrets, or run \`lunora dev\` in an interactive terminal to scaffold automatically.` ); } try { (options.fillSecrets ?? fillDevSecrets)({ cwd, info: (message) => { options.logger.info(message); } }); } catch { } }; const afterWorkerSpawn = (plan, cwd, logger, studioUrl) => { if (plan.flavor !== "wrangler") { return void 0; } if (studioUrl !== void 0) { updateDevServerState(cwd, { studioUrl }); } let containerLogs; try { containerLogs = startContainerLogStreaming(cwd, logger); } catch { } printBanner(logger, plan, studioUrl); return containerLogs; }; const claimStartRecord = (plan, cwd) => { const handoffPid = Number(process.env[DEV_HANDOFF_ENV]); const claim = claimDevServerState( cwd, { background: process.env[DEV_DAEMON_ENV] === "1", logFile: process.env[DEV_LOG_FILE_ENV], mode: "cli", pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString(), url: plan.workerOrigin }, Number.isInteger(handoffPid) && handoffPid > 0 ? { supersedePid: handoffPid } : void 0 ); return claim.ok ? void 0 : claim.existing; }; const buildDevPlan = async (options) => { const cwd = options.cwd ?? process.cwd(); const flavor = options.flavor ?? detectDevFlavor(cwd); const workerPort = flavor === "wrangler" ? await resolveWorkerPort(options, cwd) : options.workerPort; return planDevCommand({ ...options, cwd, flavor, workerPort }); }; const superviseWorkers = async (worker, sidecar, logger) => { let sigintCount = 0; let escalationTimer; const killChildren = (signal) => { worker.kill(signal); sidecar?.kill(signal); }; const onSigint = () => { sigintCount += 1; if (sigintCount === 1) { logger.info("received SIGINT — shutting down (press Ctrl-C again to force-kill)"); killChildren("SIGTERM"); escalationTimer = setTimeout(() => { killChildren("SIGKILL"); }, SIGINT_GRACE_MS); escalationTimer.unref(); } else { killChildren("SIGKILL"); } }; const onSigterm = () => { killChildren("SIGTERM"); }; process.on("SIGINT", onSigint); process.on("SIGTERM", onSigterm); const exits = [ worker.exited.then((code) => { return { code, who: "worker" }; }) ]; if (sidecar !== void 0) { exits.push( sidecar.exited.then((code) => { return { code, who: "sidecar" }; }) ); } const first = await Promise.race(exits); if (sidecar !== void 0) { if (first.who === "sidecar") { logger.warn("[worker] the Lunora sidecar (wrangler dev) exited — shutting down the framework dev server"); worker.kill("SIGTERM"); } else { sidecar.kill("SIGTERM"); } await Promise.allSettled([worker.exited, sidecar.exited]); } if (escalationTimer) { clearTimeout(escalationTimer); } process.off("SIGINT", onSigint); process.off("SIGTERM", onSigterm); return first.code; }; const startStudioBestEffort = async (options, plan, cwd, logger) => { if (!plan.studioEnabled) { return void 0; } try { return await (options.startStudio ?? startStudioServer)({ cwd, logger: { warnOnce: (message) => { logger.warn(message); } }, port: plan.studioPort, workerOrigin: plan.workerOrigin }); } catch (error) { logger.warn(`studio server failed to start (${error instanceof Error ? error.message : String(error)}) — continuing without it`); return void 0; } }; const ensureSidecarGenerated = (plan, options, cwd, logger) => { if (plan.sidecar === void 0) { return; } try { runCodegen({ apiSpec: options.apiSpec, lunoraDirectory: "lunora", projectRoot: cwd }); } catch (error) { logger.warn(`codegen (pre-sidecar) failed: ${error instanceof Error ? error.message : String(error)} — the framework dev server will retry`); } }; const runDevCommand = async (options) => { const plan = await buildDevPlan(options); const { logger } = options; const cwd = plan.wrangler.cwd ?? process.cwd(); const handles = { remoteCleanup: plan.remote.cleanup }; try { const handoffPid = Number(process.env[DEV_HANDOFF_ENV]); const existing = readLiveDevServerState(cwd); if (existing !== void 0 && existing.pid !== process.pid && existing.pid !== handoffPid) { reportExistingServer(logger, existing); return { code: 0, plan }; } const incumbent = claimStartRecord(plan, cwd); if (incumbent !== void 0) { reportExistingServer(logger, incumbent); return { code: 0, plan }; } if (plan.flavor === "vite" || plan.flavor === "framework-worker") { plan.wrangler.env = { ...plan.wrangler.env, [DEV_HANDOFF_ENV]: String(process.pid) }; } await offerDevVariablesScaffold(options, cwd); logger.info( plan.flavor === "vite" ? "starting vite dev (worker + studio + codegen run inside Vite via @lunora/vite)" : "starting wrangler dev + studio" ); if (plan.ipv4LoopbackForced) { logger.info( "no IPv6 loopback (::1) on this host — binding the worker to 127.0.0.1 (--ip) so wrangler dev doesn't crash. Pin `dev.ip` in wrangler.jsonc to override." ); } if (plan.codegenEnabled) { handles.codegen = (options.startCodegen ?? startCodegenWatch)({ apiSpec: options.apiSpec, logger, projectRoot: cwd }); } handles.studio = await startStudioBestEffort(options, plan, cwd, logger); const studioUrl = handles.studio?.url; if (plan.frameworkHint !== void 0) { logger.warn(plan.frameworkHint); } ensureSidecarGenerated(plan, options, cwd, logger); const spawn = options.startWorker ?? defaultWorkerSpawner; const worker = spawn(plan.wrangler, logger); const sidecar = plan.sidecar === void 0 ? void 0 : spawn(plan.sidecar, logger); handles.containerLogs = afterWorkerSpawn(plan, cwd, logger, studioUrl); printAgentRulesHint(logger, cwd); const code = await superviseWorkers(worker, sidecar, logger); return { code, plan }; } finally { clearDevServerState(cwd, process.pid); await teardown(handles); } }; const execute = defineHandler(async ({ argument, cwd, logger, options }) => { const json = options.json === true; const dispatched = runLifecycleSubcommand({ cwd, json, lines: options.lines, logger, subcommand: argument[0] }); if (dispatched !== void 0) { return dispatched; } const isDaemon = process.env[DEV_DAEMON_ENV] === "1"; const agent = isDaemon ? void 0 : detectAiAgent(); const jsonLogs = json || agent !== void 0; if (jsonLogs) { forceJsonLogging(); } if (agent !== void 0 && options.background !== true) { logger.info( `AI agent detected (${agent.name} via ${agent.variable}) — starting the dev server in background mode with JSON logs. Set LUNORA_AGENT_MODE=0 to opt out.` ); } const remote = resolveRemoteEnabled({ configPreference: readProjectRemotePreference(cwd), envValue: process.env["LUNORA_REMOTE"], flag: options.remote }); if (!isDaemon && (options.background === true || agent !== void 0)) { const existing = readLiveDevServerState(cwd); if (existing !== void 0) { reportExistingServer(logger, existing); return { code: 0 }; } return startBackground({ cwd, jsonLogs, logger, options, remote }); } return runDevCommand({ apiSpec: parseApiSpec(options.apiSpec), // cerebro parses `--no-codegen`/`--no-studio` as the negation of the // `codegen`/`studio` booleans (runtime key drops the `no-` prefix), so a // passed flag arrives as `false`, absent as `true` (the option default). codegen: options.codegen === false ? false : void 0, cwd, logger, port: options.port, remote, studio: options.studio === false ? false : void 0, workerPort: options.workerPort }); }); export { detectDevFlavor, execute, planDevCommand, resolveRemotePlan, resolveWorkerPort, runDevCommand };