@lunora/cli
Version:
The Lunora CLI: init, dev, deploy, codegen, run, reset, and migrate commands
1,182 lines (1,174 loc) • 42.3 kB
JavaScript
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 };