Ctrl K
ComunidadGuías…

scripts/dev.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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<Uint8Array>,
  prefix: string,
  output: NodeJS.WriteStream,
): Promise<void> {
  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<void> {
  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<string, string | undefined> {
  // 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<string, string | undefined> = { ...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<Uint8Array>, prefix, process.stdout);
  prefixLines(proc.stderr as ReadableStream<Uint8Array>, 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<void> {
  // 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<void> {
  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<void> {
  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);
});