Ctrl K
ComunidadGuías…

tools/devkit/dev.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/*
 * Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
 * http://github.com/fonoster/fonoster
 *
 * This file is part of Fonoster
 *
 * Licensed under the MIT License (the "License");
 * you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 *
 *    https://opensource.org/licenses/MIT
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
// Runs the components we develop from source, with reload, inside the dev container
// (CONTRIBUTING.md). The dependencies (Postgres, NATS, Routr, RTPEngine, MinIO, Envoy) are the
// dev container's other services.
//
//   npm run dev                       every component, each line prefixed by its name
//   npm run dev -- appbridge autopilot   only those
//
// Each process gets the values of .dev/.env (with ${NAME} expanded, as Compose's env_file does)
// for the ${NAME} placeholders of its config file, .dev/config/<service>.yaml (passed explicitly:
// the AppBridge replicas the tests start look in /config). The Roomr node is a dev container
// service (the bundle's pinned image), not run from source.
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, symlinkSync, watch } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const DEV = join(ROOT, ".dev");
const TSX = join(ROOT, "node_modules", ".bin", "tsx");
const config = (service) => join(DEV, "config", `${service}.yaml`);

const COLORS = [36, 35, 33, 32, 34, 31];
const NAMES = ["fakes", "apiserver", "appbridge", "autopilot", "dashboard"];
const width = Math.max(...NAMES.map((n) => n.length));
const children = new Set();
let stopping = false;

function devEnv() {
  const cli = join(ROOT, "mods/common/dist/config/envFile.js");
  if (!existsSync(join(DEV, ".env")) || !existsSync(cli)) {
    console.error(
      "No dev config or no build: run `bash .devcontainer/post-create.sh` (it runs when the dev container is created)."
    );
    process.exit(1);
  }
  const { readEnvFile, expandEnv } = createRequire(import.meta.url)(cli);
  const entries = readEnvFile(join(DEV, ".env"));
  // A value may refer to a name further down (initialize.sh wrote the dev keys in no set order
  // before #1272): the second pass sees every name the first one expanded.
  const first = expandEnv(entries, process.env);
  return expandEnv(entries, { ...first, ...process.env });
}

function prefixer(name) {
  const color = COLORS[NAMES.indexOf(name) % COLORS.length];
  const tag = `\x1b[${color}m${name.padEnd(width)}\x1b[0m |`;
  return (stream) =>
    createInterface({ input: stream }).on("line", (line) =>
      process.stdout.write(`${tag} ${line}\n`)
    );
}

function run(name, command, args, options = {}) {
  const out = prefixer(name);
  const child = spawn(command, args, {
    cwd: options.cwd ?? ROOT,
    env: { ...process.env, ...ENV, FORCE_COLOR: "1", ...options.env },
    stdio: ["ignore", "pipe", "pipe"],
    detached: true
  });
  children.add(child);
  out(child.stdout);
  out(child.stderr);
  child.on("exit", (code, signal) => {
    children.delete(child);
    if (stopping && children.size === 0) process.exit(0);
    if (!stopping && !options.quiet)
      process.stdout.write(
        `${name.padEnd(width)} | exited (${signal ?? `code ${code}`})\n`
      );
    options.onExit?.(code);
  });
  return child;
}

// SIGTERM, then SIGKILL: a reload should not wait for a process that drains on SIGTERM.
function stop(child) {
  const kill = (signal) => {
    try {
      process.kill(-child.pid, signal);
    } catch {
      // already gone
    }
  };
  kill("SIGTERM");
  setTimeout(() => child.exitCode === null && kill("SIGKILL"), 3000).unref();
}

const tsxWatch = (entry, ...args) => [
  TSX,
  ["watch", "--clear-screen=false", entry, ...args]
];

// The apiserver applies its migrations and seed with its own entrypoint (as in the container),
// then serves from source under tsx watch. The entrypoint expects <root>/core and
// <root>/identity, each with a schema.prisma and its migrations.
function apiserver() {
  const root = join(DEV, "migrations");
  mkdirSync(root, { recursive: true });
  for (const [dir, mod] of [
    ["core", "apiserver"],
    ["identity", "identity"]
  ])
    if (!existsSync(join(root, dir)))
      symlinkSync(join("..", "..", "mods", mod), join(root, dir));
  run(
    "apiserver",
    TSX,
    [
      "mods/apiserver/src/bin/entrypoint.ts",
      "--migrate-only",
      "--migrations-root",
      root,
      "--config",
      config("fonoster")
    ],
    {
      quiet: true,
      onExit: (code) => {
        if (stopping) return;
        if (code !== 0) {
          process.stdout.write("apiserver | migrations failed; not starting\n");
          return;
        }
        // The first user (DEV_OWNER_* in .dev/.env), once: fonoster-owner leaves an existing one.
        const owner = spawnSync(
          TSX,
          [
            "mods/apiserver/src/bin/owner.ts",
            "create",
            ENV.DEV_OWNER_EMAIL || "admin@fonoster.local",
            "--config",
            config("fonoster")
          ],
          {
            cwd: ROOT,
            env: { ...process.env, ...ENV },
            input: ENV.DEV_OWNER_PASSWORD || "",
            encoding: "utf8"
          }
        );
        process.stdout.write(
          `apiserver | ${(owner.stdout || owner.stderr || "").trim()}\n`
        );
        run(
          "apiserver",
          ...tsxWatch(
            "mods/apiserver/src/index.ts",
            "--config",
            config("fonoster")
          )
        );
      }
    }
  );
}

const COMPONENTS = {
  // Fake TTS (:7001), STT (:7002) and LLM (:7003): .devcontainer/config points AppBridge and
  // Autopilot at them, so calls work without vendor keys.
  fakes: () => run("fakes", TSX, ["mods/conformance/src/fakes/serve.ts"]),
  apiserver,
  appbridge: () =>
    run(
      "appbridge",
      ...tsxWatch("mods/appbridge/src/main.ts", config("appbridge"))
    ),
  autopilot: () =>
    run(
      "autopilot",
      ...tsxWatch(
        "mods/autopilot/src/session/main.ts",
        "--config",
        config("autopilot")
      )
    ),
  // Vite on :3030; Envoy (the bundle's) serves it with the API at PUBLIC_URL.
  dashboard: () =>
    run(
      "dashboard",
      "npm",
      ["run", "dev", "-w", "@fonoster/dashboard", "--", "--host", "0.0.0.0"],
      {
        env: { DASHBOARD_API_URL: ENV.PUBLIC_URL }
      }
    )
};

const wanted = process.argv.slice(2);
const unknown = wanted.filter((n) => !(n in COMPONENTS));
if (unknown.length) {
  console.error(
    `unknown component ${unknown.join(", ")}; one of ${NAMES.join(", ")}`
  );
  process.exit(2);
}

const ENV = devEnv();
for (const name of wanted.length ? wanted : NAMES) COMPONENTS[name]();
if (!wanted.length || wanted.includes("dashboard"))
  process.stdout.write(
    `\nDashboard: ${ENV.PUBLIC_URL} (sign in: npm run dev:info)\n\n`
  );

const shutdown = () => {
  if (stopping) return;
  stopping = true;
  children.forEach(stop);
  setTimeout(() => process.exit(0), 5000).unref();
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);