#!/usr/bin/env bun /** * `bun run dev` — supervised dual-process development mode. * * Local developer tooling, not part of the runtime. Spawns the API server in * watch mode (`bun --watch src/cli/index.ts serve`) plus the Vite web dev * server, with unified prefixed output and readiness gating. The runtime binary * (`src/cli/index.ts`) only serves; this script orchestrates the dev loop on top * of it, alongside the other `scripts/dev-*.ts` tooling. */ import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { parseArgs } from "node:util"; import { type Subprocess, spawn } from "bun"; import { loadConfig } from "../src/cli/config.ts"; import { defaultWorkDir } from "../src/connectors/runtime/paths.ts"; import { log } from "../src/observability/log.ts"; import { setAppDevMode } from "../src/runtime/dev-registry.ts"; import { ensureDevInstanceConfig } from "./lib/dev-instance.ts"; import { prepareCheckout } from "./lib/dev-prepare.ts"; interface DevOptions { port: number; noWeb: boolean; config: string | undefined; debug: boolean; app?: string; appPort?: number; } /** * Prefix each line from a ReadableStream and write to an output stream. * Handles partial lines (no trailing newline) gracefully. */ async function prefixLines( stream: ReadableStream, prefix: string, output: NodeJS.WriteStream, ): Promise { const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); // Keep the last element — it's either "" (line ended with \n) or a partial line buffer = lines.pop()!; for (const line of lines) { if (line.length > 0) { output.write(`${prefix} ${line}\n`); } } } // Flush remaining buffer if (buffer.length > 0) { output.write(`${prefix} ${buffer}\n`); } } catch { // Stream closed — normal during shutdown } } /** * Poll the API's /v1/health endpoint until it responds OK or the deadline elapses. * Used to gate Vite dev server spawns on API readiness so they don't fire requests * into a not-yet-listening port (which produces noisy ECONNREFUSED stack traces). */ async function waitForHealth(port: number, opts: { timeoutMs: number }): Promise { const deadline = Date.now() + opts.timeoutMs; const url = `http://localhost:${port}/v1/health`; while (Date.now() < deadline) { try { const res = await fetch(url); if (res.ok) return; } catch { // Port not bound yet — expected during startup. } await Bun.sleep(100); } throw new Error(`API did not become ready within ${opts.timeoutMs}ms`); } /** Build the `bun --watch` API-server argv from the dev options. */ function buildApiArgs( cliEntry: string, port: number, config: string | undefined, debug: boolean, ): string[] { const apiArgs = ["bun", "--watch", cliEntry, "serve", "--port", String(port)]; // Only pass --config if explicitly provided — otherwise let serve use defaults if (config) { apiArgs.push("--config", resolve(config)); } if (debug) apiArgs.push("--debug"); return apiArgs; } /** Build the dev-mode env, auto-filling NB_WEB_URL for the post-OAuth SPA bounce. */ function buildDevEnv(noWeb: boolean): Record { // Auto-derive NB_WEB_URL so the API knows where to bounce the user // back after an OAuth callback. Without this, the callback can only // redirect to NB_API_URL (the API port), which doesn't serve the SPA // in dev — the user lands on a workspace_error JSON page instead of // the Connections tab. // // Mirrors web/vite.config.ts: NB_WEB_PORT env if set, else default 27246. // Operator-supplied NB_WEB_URL wins (the auto-derivation only fills // in the unset case). const devEnv: Record = { ...process.env }; if (!devEnv.NB_WEB_URL && !noWeb) { const webPort = process.env.NB_WEB_PORT ?? "27246"; devEnv.NB_WEB_URL = `http://localhost:${webPort}`; } return devEnv; } /** Track a child for shutdown and stream its stdout/stderr under a log prefix. */ function pipeChild(children: Subprocess[], proc: Subprocess, prefix: string): void { children.push(proc); prefixLines(proc.stdout as ReadableStream, prefix, process.stdout); prefixLines(proc.stderr as ReadableStream, prefix, process.stderr); } /** Gate on API readiness; on timeout, SIGTERM the API child and exit the process. */ async function waitForApiReadyOrExit(apiProc: Subprocess, port: number): Promise { // Gate Vite spawns on API readiness. Without this, Vite proxies fire requests // into a not-yet-listening API and the user sees ECONNREFUSED stack traces // until the sources finish loading. log.info("[dev] Waiting for API to become ready..."); try { await waitForHealth(port, { timeoutMs: 30_000 }); log.info("[dev] API ready"); } catch { log.info("[dev] API failed to become ready within 30s. Exiting."); try { apiProc.kill("SIGTERM"); } catch { // Already dead } process.exit(1); } } /** Start the Vite web dev server unless disabled or missing, tracking it under [web]. */ function startWebServer(noWeb: boolean, children: Subprocess[]): Subprocess | undefined { if (noWeb) return undefined; const webDir = join(process.cwd(), "web"); if (!existsSync(join(webDir, "package.json"))) { log.info("[dev] Warning: web/package.json not found. Skipping web dev server."); return undefined; } log.info("[dev] Starting web dev server..."); const webProc = spawn({ cmd: ["bun", "run", "dev"], cwd: webDir, stdout: "pipe", stderr: "pipe", env: { ...process.env }, }); pipeChild(children, webProc, "[web]"); return webProc; } /** Start the app's Vite dev server for --app, registering dev mode and tracking it under [app]. */ function startAppServer( appPath: string | undefined, appPort: number, children: Subprocess[], ): void { if (!appPath) return; const resolvedAppPath = resolve(appPath); const manifestPath = join(resolvedAppPath, "manifest.json"); if (!existsSync(manifestPath)) { log.info(`[dev] Warning: ${manifestPath} not found. Skipping app dev server.`); return; } try { const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); const appNameFromManifest = manifest.name ?? "unknown-app"; const devUrl = `http://localhost:${appPort}`; setAppDevMode(appNameFromManifest, devUrl); log.info(`[dev] Starting app dev server for ${appNameFromManifest} on port ${appPort}...`); const appProc = spawn({ cmd: ["npx", "vite", "--port", String(appPort)], cwd: resolvedAppPath, stdout: "pipe", stderr: "pipe", env: { ...process.env }, }); pipeChild(children, appProc, "[app]"); } catch (err) { log.info(`[dev] Failed to read app manifest: ${err}`); } } /** Send a signal to every tracked child, ignoring already-dead ones. */ function signalChildren(children: Subprocess[], signal: "SIGTERM" | "SIGKILL"): void { for (const child of children) { try { child.kill(signal); } catch { // Already dead } } } /** * Install SIGINT/SIGTERM handlers that SIGTERM all children (SIGKILL after 5s); * returns a reader for the shutting-down flag. */ function installShutdown(children: Subprocess[]): () => boolean { let shuttingDown = false; const shutdown = () => { if (shuttingDown) { // Second signal — force exit process.exit(1); } shuttingDown = true; log.info("\n[dev] Shutting down..."); signalChildren(children, "SIGTERM"); // Force kill after 5s setTimeout(() => { signalChildren(children, "SIGKILL"); process.exit(1); }, 5000); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); return () => shuttingDown; } async function runDev(options: DevOptions): Promise { const { port, noWeb, config, debug, app: appPath, appPort = 5173 } = options; const children: Subprocess[] = []; // A fresh clone has no web deps and no app UI dists, and the README quickstart // builds neither. Runs after arg parsing so `--no-web` does not install // dependencies the run will never load. prepareCheckout(join(import.meta.dir, ".."), { web: !noWeb }); // The runtime entry, relative to this script (scripts/ -> ../src/cli/index.ts). const cliEntry = join(import.meta.dir, "..", "src", "cli", "index.ts"); // `serve` refuses a workdir with no instance.json. Resolve the workdir the // way `serve` will and give it the dev adapter if it has none. const { workDir } = loadConfig({ config: config ? resolve(config) : undefined, defaultWorkDir: defaultWorkDir(), }); const written = workDir ? ensureDevInstanceConfig(workDir) : null; if (written) log.info(`[dev] Wrote ${written}: the dev identity provider, no login`); // --- API server with bun --watch --- const apiArgs = buildApiArgs(cliEntry, port, config, debug); const devEnv = buildDevEnv(noWeb); log.info("[dev] Starting API server with file watching..."); const apiProc = spawn({ cmd: apiArgs, stdout: "pipe", stderr: "pipe", env: devEnv, }); pipeChild(children, apiProc, "[api]"); await waitForApiReadyOrExit(apiProc, port); // --- Web dev server (unless --no-web) --- const webProc = startWebServer(noWeb, children); // --- App dev server (when --app is specified) --- startAppServer(appPath, appPort, children); // --- Shutdown handling --- const isShuttingDown = installShutdown(children); // Wait for API process to exit (bun --watch keeps it alive) const apiExitCode = await apiProc.exited; if (!isShuttingDown()) { log.info(`[dev] API server exited with code ${apiExitCode}`); // Clean up web process if still running if (webProc) { try { webProc.kill("SIGTERM"); } catch { // Already dead } } } process.exit(apiExitCode ?? 0); } async function main(): Promise { const rest = process.argv.slice(2); const { values } = parseArgs({ args: rest, // strict:false so the `--no-web` negation token passes through; we read it // off argv directly since parseArgs has no boolean-negation support. strict: false, options: { config: { type: "string", short: "c" }, port: { type: "string" }, app: { type: "string" }, "app-port": { type: "string" }, debug: { type: "boolean" }, }, }); await runDev({ port: values.port ? Number(values.port) : 27247, noWeb: rest.includes("--no-web"), config: values.config as string | undefined, debug: (values.debug as boolean | undefined) ?? false, app: values.app as string | undefined, appPort: values["app-port"] ? Number(values["app-port"]) : undefined, }); } main().catch((err) => { log.error(`[dev] Fatal: ${err}`); process.exit(1); });