mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-09-28 05:54:50 +08:00
1726 lines
62 KiB
JavaScript
1726 lines
62 KiB
JavaScript
/**
|
|
* Development Stack with Automation Service
|
|
*
|
|
* Extends agent-canvas's dev-safe.mjs to additionally run the OpenHands Automation
|
|
* backend via uvx. No cloning required - runs directly from git reference.
|
|
*
|
|
* Uses a standalone ingress proxy to route traffic to multiple backends.
|
|
*
|
|
* Architecture:
|
|
* ┌──────────────────────────────────────────────────────────────────────────┐
|
|
* │ http://localhost:8000 (Ingress Proxy) │
|
|
* │ /api/automation/* → Automation Backend │
|
|
* │ /api/*, /sockets → Agent Server │
|
|
* │ /* → Vite Dev Server │
|
|
* └──────────────────────────────────────────────────────────────────────────┘
|
|
* │ │ │
|
|
* ▼ ▼ ▼
|
|
* ┌─────────────┐ ┌───────────────┐ ┌──────────────────┐
|
|
* │ Vite │ │ Agent Server │ │ Automation │
|
|
* │ :3001 │ │ (uvx) :18000 │ │ Backend (uvx) │
|
|
* │ │ │ │ │ :18001 │
|
|
* └─────────────┘ └───────────────┘ └──────────────────┘
|
|
*
|
|
* Usage:
|
|
* node scripts/dev-with-automation.mjs
|
|
* node scripts/dev-with-automation.mjs --automation-ref feat/my-branch
|
|
* node scripts/dev-with-automation.mjs --port 12000
|
|
*
|
|
* Environment variables:
|
|
* - PORT: Ingress port (default: 8000)
|
|
* - OH_AUTOMATION_GIT_REF: Git ref for automation (overrides default version)
|
|
* - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a local software-agent-sdk
|
|
* checkout. Highest precedence for agent-server source selection: rebuilds
|
|
* the agent-server from local source and installs openhands-sdk,
|
|
* openhands-tools and openhands-workspace as editable so source edits are
|
|
* picked up without manual reinstall.
|
|
* - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server
|
|
* Secrets:
|
|
* The session API key is automatically seeded into agent-server secrets
|
|
* as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
|
|
* Both the agent-server and automation backend use the same key value
|
|
* and the same `X-Session-API-Key` header for authentication.
|
|
* AUTOMATION_KV_SECRET is derived from the session key if not set explicitly,
|
|
* enabling the KV store out of the box for local development.
|
|
*/
|
|
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { mkdirSync, existsSync, readFileSync } from "node:fs";
|
|
import { join, resolve, dirname, isAbsolute } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { homedir } from "node:os";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import process from "node:process";
|
|
|
|
import {
|
|
assertPortsFree,
|
|
buildAgentServerCommand,
|
|
buildSafeDevConfig,
|
|
buildAgentServerEnv,
|
|
buildNpmScriptCommand,
|
|
buildRuntimeServicesInfo,
|
|
formatMissingUvxGuidance,
|
|
validateFrontendDependencies,
|
|
validateLocalAgentServerPath,
|
|
} from "./dev-safe.mjs";
|
|
import {
|
|
createShutdownHookRegistry,
|
|
getProcessTreeSpawnOptions,
|
|
isProcessRunning,
|
|
resolveWindowsCommand,
|
|
signalProcessTree,
|
|
} from "./dev-process-utils.mjs";
|
|
import { fileLog, stripAnsi } from "./logger.mjs";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = resolve(__dirname, "..");
|
|
|
|
// ── Centralized config (single source of truth for versions, ports, etc.) ───
|
|
const SHARED_DEFAULTS = JSON.parse(
|
|
readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"),
|
|
);
|
|
|
|
const DEFAULT_AUTOMATION_REPO = "https://github.com/OpenHands/automation";
|
|
const DEFAULT_AUTOMATION_PACKAGE = SHARED_DEFAULTS.packages.automation;
|
|
const DEFAULT_AUTOMATION_VERSION = SHARED_DEFAULTS.versions.automation;
|
|
const DEFAULT_AUTOMATION_SDK_VERSION = SHARED_DEFAULTS.versions.agentServer;
|
|
const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer;
|
|
const DEFAULT_AUTOMATION_PORT = SHARED_DEFAULTS.ports.automation;
|
|
const DEFAULT_POSTHOG_API_KEY = SHARED_DEFAULTS.telemetry.posthogApiKey;
|
|
const DEFAULT_POSTHOG_HOST = SHARED_DEFAULTS.telemetry.posthogHost;
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Terminal Styling
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const c = {
|
|
reset: "\x1b[0m",
|
|
bold: "\x1b[1m",
|
|
dim: "\x1b[2m",
|
|
red: "\x1b[31m",
|
|
green: "\x1b[32m",
|
|
yellow: "\x1b[33m",
|
|
blue: "\x1b[34m",
|
|
magenta: "\x1b[35m",
|
|
cyan: "\x1b[36m",
|
|
};
|
|
|
|
function logService(name, message, color = c.reset) {
|
|
const ts = new Date().toISOString().split("T")[1].split(".")[0];
|
|
console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`);
|
|
fileLog("info", `[${name}] ${stripAnsi(message)}`);
|
|
}
|
|
|
|
function logStep(step, message) {
|
|
console.log(`${c.cyan}[${step}]${c.reset} ${message}`);
|
|
fileLog("info", `[${step}] ${message}`);
|
|
}
|
|
|
|
function logSuccess(message) {
|
|
console.log(`${c.green}✓${c.reset} ${message}`);
|
|
fileLog("info", `✓ ${message}`);
|
|
}
|
|
|
|
function logError(message) {
|
|
console.error(`${c.red}✗${c.reset} ${message}`);
|
|
fileLog("error", `✗ ${stripAnsi(message)}`);
|
|
}
|
|
|
|
/**
|
|
* Parse one JSON log line produced by the SDK's JsonFormatter and return a
|
|
* single-line human-readable string + an appropriate ANSI color.
|
|
*
|
|
* Returns null for non-JSON lines so callers can fall back to the raw text.
|
|
*
|
|
* @param {string} rawLine
|
|
* @returns {{ text: string; color: string } | null}
|
|
*/
|
|
function parseAgentServerLogLine(rawLine) {
|
|
try {
|
|
const obj = JSON.parse(rawLine);
|
|
if (!obj.levelname || obj.message === undefined) return null;
|
|
const level = obj.levelname.padEnd(8);
|
|
const location =
|
|
obj.filename && obj.lineno ? ` ${obj.filename}:${obj.lineno}` : "";
|
|
const text = `${level} ${obj.message}${location}`;
|
|
const lvl = obj.levelname;
|
|
const color =
|
|
lvl === "DEBUG"
|
|
? c.dim
|
|
: lvl === "WARNING"
|
|
? c.yellow
|
|
: lvl === "ERROR" || lvl === "CRITICAL"
|
|
? c.red
|
|
: c.blue;
|
|
return { text, color };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Configuration
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
const config = {
|
|
port: null,
|
|
automationGitRef: null,
|
|
automationRepo: null,
|
|
verbose: false,
|
|
static: false,
|
|
dynamic: false,
|
|
staticDir: null,
|
|
skipBuild: false,
|
|
public: false,
|
|
frontendOnly: false,
|
|
backendOnly: false,
|
|
};
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
switch (args[i]) {
|
|
case "-p":
|
|
case "--port":
|
|
config.port = parseInt(args[++i], 10);
|
|
break;
|
|
case "--automation-ref":
|
|
config.automationGitRef = args[++i];
|
|
break;
|
|
case "--automation-repo":
|
|
config.automationRepo = args[++i];
|
|
break;
|
|
case "-v":
|
|
case "--verbose":
|
|
config.verbose = true;
|
|
break;
|
|
case "--static":
|
|
config.static = true;
|
|
break;
|
|
case "--dynamic":
|
|
config.dynamic = true;
|
|
break;
|
|
case "--static-dir":
|
|
config.staticDir = args[++i];
|
|
break;
|
|
case "--skip-build":
|
|
config.skipBuild = true;
|
|
break;
|
|
case "--public":
|
|
config.public = true;
|
|
break;
|
|
case "--frontend-only":
|
|
config.frontendOnly = true;
|
|
break;
|
|
case "--backend-only":
|
|
config.backendOnly = true;
|
|
break;
|
|
case "-h":
|
|
case "--help":
|
|
showHelp();
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
function showHelp() {
|
|
console.log(`
|
|
Agent Canvas + Automation Development Stack
|
|
|
|
Runs agent-canvas with the automation backend (via uvx, no clone needed).
|
|
Uses a standalone ingress proxy to route traffic.
|
|
|
|
USAGE:
|
|
node scripts/dev-with-automation.mjs [options]
|
|
|
|
OPTIONS:
|
|
-p, --port <port> Ingress port (default: 8000)
|
|
--automation-ref <ref> Git ref for automation (branch/tag/SHA)
|
|
--automation-repo <url> Git repo URL (default: ${DEFAULT_AUTOMATION_REPO})
|
|
--static Serve an existing production build instead of Vite
|
|
--static-dir <dir> Static build directory (default: build/)
|
|
--skip-build Reuse build/ when the launcher builds static assets
|
|
--dynamic Force Vite dev server when a wrapper defaults static
|
|
--frontend-only Start only the frontend behind ingress
|
|
--backend-only Start only agent-server + automation behind ingress
|
|
-v, --verbose Show detailed output
|
|
-h, --help Show this help
|
|
|
|
ENVIRONMENT VARIABLES:
|
|
PORT Alternative to --port
|
|
OH_AUTOMATION_GIT_REF Git ref for automation (overrides default version)
|
|
OH_AUTOMATION_VERSION Specific PyPI version for automation (default: ${DEFAULT_AUTOMATION_VERSION})
|
|
OH_AUTOMATION_LOCAL_PATH Absolute path to a local automation checkout (overridden only by --automation-git-ref)
|
|
OH_AGENT_SERVER_LOCAL_PATH Absolute path to a local software-agent-sdk checkout (highest precedence)
|
|
OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK (overrides default version)
|
|
OH_AGENT_SERVER_VERSION Specific PyPI version for agent-server
|
|
OH_SECRET_KEY Secret key for sessions
|
|
|
|
SECRETS:
|
|
The session API key is automatically seeded into agent-server secrets
|
|
as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
|
|
Both backends (agent-server and automation) share the same key value.
|
|
AUTOMATION_KV_SECRET defaults to the session key so the KV store works
|
|
out of the box; override with an explicit value for stronger isolation.
|
|
|
|
ACCESS POINTS:
|
|
Main UI: http://localhost:PORT/
|
|
API Docs: http://localhost:PORT/api/automation/docs
|
|
`);
|
|
}
|
|
|
|
/**
|
|
* Fail fast on an unusable OH_AUTOMATION_LOCAL_PATH instead of letting
|
|
* `uv run --project <bad path>` exit on its own -- that leaves the rest of the
|
|
* stack up and the automations UI just reporting "backend unavailable", with
|
|
* nothing pointing at the env var. Mirrors validateLocalAgentServerPath.
|
|
*/
|
|
function validateLocalAutomationPath(localPath) {
|
|
if (!isAbsolute(localPath)) {
|
|
throw new Error(
|
|
`OH_AUTOMATION_LOCAL_PATH must be an absolute path, got: ${localPath}`,
|
|
);
|
|
}
|
|
if (!existsSync(localPath)) {
|
|
throw new Error(`OH_AUTOMATION_LOCAL_PATH does not exist: ${localPath}`);
|
|
}
|
|
const projectFile = join(localPath, "pyproject.toml");
|
|
if (!existsSync(projectFile)) {
|
|
throw new Error(
|
|
`OH_AUTOMATION_LOCAL_PATH is not a Python project (no pyproject.toml): ${projectFile}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the uvx command for running automation backend.
|
|
*
|
|
* Environment variables (highest precedence first):
|
|
* - OH_AUTOMATION_LOCAL_PATH: Absolute path to a local checkout
|
|
* - OH_AUTOMATION_GIT_REF: Git commit SHA or branch name
|
|
* - OH_AUTOMATION_VERSION: Specific PyPI version (e.g., "1.0.0a1")
|
|
*
|
|
* If none are set, defaults to the released version specified by
|
|
* DEFAULT_AUTOMATION_VERSION. Set OH_AUTOMATION_GIT_REF to use a
|
|
* git branch or commit instead.
|
|
*/
|
|
function buildAutomationCommand(env = process.env) {
|
|
const localPath = env.OH_AUTOMATION_LOCAL_PATH;
|
|
const gitRef = env.OH_AUTOMATION_GIT_REF;
|
|
const version = env.OH_AUTOMATION_VERSION;
|
|
const repoUrl = env.OH_AUTOMATION_REPO || DEFAULT_AUTOMATION_REPO;
|
|
|
|
const uvxArgs = [];
|
|
let source = "";
|
|
|
|
if (localPath) {
|
|
// Run straight from a local checkout via `uv run --project`, so
|
|
// uncommitted working-tree changes are picked up. Outranks the other
|
|
// automation env vars, mirroring OH_AGENT_SERVER_LOCAL_PATH for the
|
|
// agent-server SDK; buildConfig drops it when --automation-git-ref asks
|
|
// for a specific ref.
|
|
return {
|
|
command: "uv",
|
|
args: [
|
|
"run",
|
|
"--project",
|
|
localPath,
|
|
"uvicorn",
|
|
"openhands.automation.app:app",
|
|
],
|
|
source: `local (${localPath})`,
|
|
};
|
|
}
|
|
|
|
if (gitRef) {
|
|
// Use git ref - refresh to ensure latest commit is fetched
|
|
const gitUrl = `git+${repoUrl}@${gitRef}`;
|
|
uvxArgs.push(
|
|
"--refresh",
|
|
"--from",
|
|
gitUrl,
|
|
"uvicorn",
|
|
"openhands.automation.app:app",
|
|
);
|
|
source = `git (${gitRef})`;
|
|
} else if (version) {
|
|
// Use specific PyPI version
|
|
uvxArgs.push(
|
|
"--from",
|
|
`${DEFAULT_AUTOMATION_PACKAGE}==${version}`,
|
|
"uvicorn",
|
|
"openhands.automation.app:app",
|
|
);
|
|
source = `PyPI (${version})`;
|
|
} else {
|
|
// Default to released PyPI version
|
|
uvxArgs.push(
|
|
"--from",
|
|
`${DEFAULT_AUTOMATION_PACKAGE}==${DEFAULT_AUTOMATION_VERSION}`,
|
|
"uvicorn",
|
|
"openhands.automation.app:app",
|
|
);
|
|
source = `PyPI (${DEFAULT_AUTOMATION_VERSION}, default)`;
|
|
}
|
|
|
|
return {
|
|
command: "uvx",
|
|
args: uvxArgs,
|
|
source,
|
|
};
|
|
}
|
|
|
|
async function buildConfig(args, env = process.env) {
|
|
// Apply args to env for buildAutomationCommand
|
|
if (args.automationGitRef) {
|
|
env.OH_AUTOMATION_GIT_REF = args.automationGitRef;
|
|
// An explicit flag outranks an ambient env var. Otherwise someone with
|
|
// OH_AUTOMATION_LOCAL_PATH exported in their shell profile would run their
|
|
// own working tree while believing they were reproducing against the ref
|
|
// they just passed.
|
|
if (env.OH_AUTOMATION_LOCAL_PATH) {
|
|
logStep(
|
|
"automation",
|
|
`--automation-git-ref ${args.automationGitRef} overrides OH_AUTOMATION_LOCAL_PATH (${env.OH_AUTOMATION_LOCAL_PATH})`,
|
|
);
|
|
delete env.OH_AUTOMATION_LOCAL_PATH;
|
|
}
|
|
}
|
|
if (args.automationRepo) {
|
|
env.OH_AUTOMATION_REPO = args.automationRepo;
|
|
}
|
|
|
|
const frontendOnly = Boolean(args.frontendOnly);
|
|
const backendOnly = Boolean(args.backendOnly);
|
|
if (frontendOnly && backendOnly) {
|
|
throw new Error(
|
|
"--frontend-only and --backend-only cannot be used together",
|
|
);
|
|
}
|
|
|
|
const launchFrontend = !backendOnly;
|
|
const launchAgentServer = !frontendOnly;
|
|
const launchAutomation = !frontendOnly;
|
|
const isPublic = args.public;
|
|
|
|
if (isPublic && frontendOnly) {
|
|
throw new Error("--public cannot be used with --frontend-only");
|
|
}
|
|
|
|
// In public mode, LOCAL_BACKEND_API_KEY is required — without it the
|
|
// auth screen has nothing to validate against.
|
|
if (isPublic && !env.LOCAL_BACKEND_API_KEY) {
|
|
logError(
|
|
"PUBLIC MODE requires LOCAL_BACKEND_API_KEY environment variable.\n" +
|
|
" Example: LOCAL_BACKEND_API_KEY=my-secret npm run dev -- --public",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Preferred ports (from env or defaults).
|
|
// OH_CANVAS_SAFE_BACKEND_PORT / OH_CANVAS_SAFE_AUTOMATION_PORT /
|
|
// OH_CANVAS_SAFE_VITE_PORT allow tests (and advanced users) to redirect
|
|
// internal service ports without affecting the production default.
|
|
const preferredIngressPort = args.port || parseInt(env.PORT, 10) || 8000;
|
|
const preferredBackendPort =
|
|
parseInt(env.OH_CANVAS_SAFE_BACKEND_PORT, 10) || DEFAULT_BACKEND_PORT;
|
|
const preferredAutomationPort =
|
|
parseInt(env.OH_CANVAS_SAFE_AUTOMATION_PORT, 10) || DEFAULT_AUTOMATION_PORT;
|
|
const preferredVitePort = parseInt(env.OH_CANVAS_SAFE_VITE_PORT, 10) || 3001;
|
|
|
|
// Fail fast if any preferred port for a service in this mode is already in use.
|
|
const requiredPorts = [{ name: "ingress", port: preferredIngressPort }];
|
|
if (launchAgentServer) {
|
|
requiredPorts.push({ name: "agent-server", port: preferredBackendPort });
|
|
}
|
|
if (launchAutomation) {
|
|
requiredPorts.push({ name: "automation", port: preferredAutomationPort });
|
|
}
|
|
if (launchFrontend) {
|
|
requiredPorts.push({ name: "frontend", port: preferredVitePort });
|
|
}
|
|
|
|
logStep("ports", "Checking ports...");
|
|
await assertPortsFree(requiredPorts);
|
|
|
|
const vscodePort = preferredBackendPort + 1000;
|
|
|
|
// API key — shared by both agent-server and automation backend.
|
|
// Both validate it via the `X-Session-API-Key` header.
|
|
// LOCAL_BACKEND_API_KEY is the single user-facing env var: if set it's
|
|
// used directly; otherwise one is auto-generated and persisted.
|
|
const stateDir =
|
|
env.OH_CANVAS_SAFE_STATE_DIR ||
|
|
join(homedir(), ".openhands", "agent-canvas");
|
|
|
|
const safeConfig = buildSafeDevConfig(projectRoot, {
|
|
...env,
|
|
OH_CANVAS_SAFE_STATE_DIR: stateDir,
|
|
OH_CANVAS_SAFE_BACKEND_PORT: preferredBackendPort.toString(),
|
|
OH_CANVAS_SAFE_VSCODE_PORT: vscodePort.toString(),
|
|
});
|
|
const sessionApiKey = safeConfig.sessionApiKey;
|
|
|
|
if (isPublic) {
|
|
logService(
|
|
"auth",
|
|
"PUBLIC MODE — key will NOT be injected into the frontend",
|
|
c.yellow,
|
|
);
|
|
logService(
|
|
"auth",
|
|
"Users must paste the LOCAL_BACKEND_API_KEY in the browser",
|
|
c.dim,
|
|
);
|
|
}
|
|
|
|
return {
|
|
// Ingress port (main entry point)
|
|
ingressPort: preferredIngressPort,
|
|
|
|
// Service ports (internal)
|
|
agentServerPort: preferredBackendPort,
|
|
autoBackendPort: preferredAutomationPort,
|
|
vitePort: preferredVitePort,
|
|
vscodePort,
|
|
// Prefix the editor is served under on the ingress origin. Carried on the
|
|
// config so the route table and the agent-server env are built from one
|
|
// value (see getLocalServiceRoutes / buildAgentServerEnv).
|
|
vscodeBasePath: safeConfig.vscodeBasePath,
|
|
|
|
// Paths
|
|
canvasPath: projectRoot,
|
|
|
|
// Data directories (same as dev-safe.mjs)
|
|
stateDir,
|
|
// Only bake the host-side workspace path when this launcher also starts
|
|
// the agent-server that can read it. In frontend-only mode the backend may
|
|
// be a tunnel/remote service, so leave VITE_WORKING_DIR unset unless the
|
|
// user explicitly supplied a backend-relative value.
|
|
viteWorkingDir: launchAgentServer
|
|
? safeConfig.workingDir
|
|
: env.VITE_WORKING_DIR,
|
|
|
|
// Auth — single key for both backends
|
|
sessionApiKey,
|
|
|
|
// Public mode — the session key should NOT be baked into the frontend
|
|
isPublic,
|
|
|
|
frontendOnly,
|
|
backendOnly,
|
|
launchFrontend,
|
|
launchAgentServer,
|
|
launchAutomation,
|
|
|
|
verbose: args.verbose,
|
|
};
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Prerequisites & Setup
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function commandExists(cmd) {
|
|
const result =
|
|
process.platform === "win32"
|
|
? spawnSync("where.exe", [cmd], { stdio: "pipe" })
|
|
: spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" });
|
|
|
|
return result.status === 0;
|
|
}
|
|
|
|
function checkPrerequisites({
|
|
checkUvx = true,
|
|
checkNpm = true,
|
|
checkFrontendDependencies = true,
|
|
} = {}) {
|
|
logStep("1/2", "Checking prerequisites...");
|
|
|
|
if (checkUvx) {
|
|
if (!commandExists("uvx")) {
|
|
const uvxGuidance = formatMissingUvxGuidance(projectRoot);
|
|
console.error(uvxGuidance);
|
|
fileLog("error", stripAnsi(uvxGuidance));
|
|
process.exit(1);
|
|
}
|
|
logSuccess("uvx found");
|
|
}
|
|
|
|
if (checkNpm) {
|
|
if (!commandExists("npm")) {
|
|
logError("npm is required but not found");
|
|
process.exit(1);
|
|
}
|
|
logSuccess("npm found");
|
|
}
|
|
|
|
if (checkFrontendDependencies) {
|
|
try {
|
|
validateFrontendDependencies(projectRoot);
|
|
} catch (error) {
|
|
logError(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
}
|
|
logSuccess("frontend dependencies found");
|
|
}
|
|
}
|
|
|
|
function ensureDirectories(config) {
|
|
const dirs = [
|
|
config.stateDir,
|
|
// Both agent-server and automation use storage; create it unconditionally
|
|
// whenever either backend service runs (i.e. not frontend-only).
|
|
...(!config.frontendOnly ? [join(config.stateDir, "storage")] : []),
|
|
];
|
|
|
|
if (config.launchAgentServer) {
|
|
dirs.push(
|
|
join(config.stateDir, "dev_conversations"),
|
|
join(config.stateDir, "workspaces"),
|
|
join(config.stateDir, "bash_events"),
|
|
);
|
|
}
|
|
|
|
if (config.launchAutomation) {
|
|
dirs.push(
|
|
// Automation DB directory — matches docker/entrypoint.sh mkdir -p behaviour.
|
|
dirname(
|
|
join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb),
|
|
),
|
|
);
|
|
}
|
|
|
|
for (const dir of dirs) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Process Management
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const processes = new Map();
|
|
const shutdownHooks = createShutdownHookRegistry((err) => {
|
|
logService("cleanup", `Cleanup hook failed: ${err.message}`, c.yellow);
|
|
});
|
|
|
|
// Optional external listener for every service log line. Set by `main()` from
|
|
// its `onServiceLog` option so embedded launchers (e.g. the Electron desktop
|
|
// app) can stream uvx download / install progress to their loading window
|
|
// without touching the terminal logging path. Receives `(name, line, level)`
|
|
// where `level` is one of "stdout" | "stderr" | "info" | "warn" | "error".
|
|
let serviceLogListener = null;
|
|
|
|
export function setServiceLogListener(listener) {
|
|
serviceLogListener = typeof listener === "function" ? listener : null;
|
|
}
|
|
|
|
function emitServiceLog(name, line, level) {
|
|
if (!serviceLogListener) return;
|
|
try {
|
|
serviceLogListener(name, line, level);
|
|
} catch {
|
|
// Never let a listener bug crash the dev stack.
|
|
}
|
|
}
|
|
|
|
function registerShutdownHook(hook) {
|
|
return shutdownHooks.add(hook);
|
|
}
|
|
|
|
function spawnService(name, command, args, options = {}) {
|
|
const proc = spawn(
|
|
resolveWindowsCommand(command),
|
|
args,
|
|
getProcessTreeSpawnOptions({
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: { ...process.env, ...options.env },
|
|
cwd: options.cwd,
|
|
}),
|
|
);
|
|
|
|
const color = options.color || c.reset;
|
|
const parseLogLine = options.parseLogLine;
|
|
|
|
proc.stdout.on("data", (data) => {
|
|
data
|
|
.toString()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.forEach((line) => {
|
|
const trimmed = line.trim();
|
|
const parsed = parseLogLine ? parseLogLine(trimmed) : null;
|
|
logService(
|
|
name,
|
|
parsed ? parsed.text : trimmed,
|
|
parsed ? parsed.color : color,
|
|
);
|
|
emitServiceLog(name, trimmed, "stdout");
|
|
});
|
|
});
|
|
|
|
proc.stderr.on("data", (data) => {
|
|
data
|
|
.toString()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.forEach((line) => {
|
|
const trimmed = line.trim();
|
|
const parsed = parseLogLine ? parseLogLine(trimmed) : null;
|
|
logService(
|
|
name,
|
|
parsed ? parsed.text : trimmed,
|
|
parsed ? parsed.color : c.yellow,
|
|
);
|
|
emitServiceLog(name, trimmed, "stderr");
|
|
});
|
|
});
|
|
|
|
proc.on("error", (error) => {
|
|
logError(`${name} failed to start: ${error.message}`);
|
|
emitServiceLog(name, `failed to start: ${error.message}`, "error");
|
|
});
|
|
|
|
proc.on("exit", (code, _signal) => {
|
|
if (code !== 0 && code !== null && !shuttingDown) {
|
|
logService(name, `Exited with code ${code}`, c.red);
|
|
emitServiceLog(name, `exited with code ${code}`, "error");
|
|
}
|
|
processes.delete(name);
|
|
});
|
|
|
|
processes.set(name, proc);
|
|
return proc;
|
|
}
|
|
|
|
async function waitForService(name, url, timeoutMs = 30000) {
|
|
const start = Date.now();
|
|
let lastError = null;
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
if (res.ok) {
|
|
logService(name, `Ready at ${url}`, c.green);
|
|
return true;
|
|
}
|
|
} catch (err) {
|
|
lastError = err;
|
|
// Keep trying
|
|
}
|
|
await delay(500);
|
|
}
|
|
|
|
const elapsed = Math.round((Date.now() - start) / 1000);
|
|
logService(name, `Timeout waiting for ${url} after ${elapsed}s`, c.red);
|
|
if (lastError) {
|
|
logService(name, `Last error: ${lastError.message}`, c.dim);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Service Starters
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const AUTOMATION_ROUTE_PREFIX = "/api/automation";
|
|
const AGENT_SERVER_ROUTE_PREFIXES = [
|
|
"/api",
|
|
"/sockets",
|
|
"/server_info",
|
|
"/health",
|
|
"/ready",
|
|
"/alive",
|
|
"/docs",
|
|
"/redoc",
|
|
"/openapi.json",
|
|
];
|
|
|
|
// This launcher starts the agent-server with `--host 127.0.0.1`, but localhost
|
|
// can resolve to ::1 first (notably on Windows), so every request this process
|
|
// or the automation backend makes to it must address IPv4 explicitly.
|
|
function getAgentServerBaseUrl(config) {
|
|
return `http://127.0.0.1:${config.agentServerPort}`;
|
|
}
|
|
|
|
function getLocalServiceRoutes(config) {
|
|
const routes = [];
|
|
|
|
// These services bind to IPv4 loopback, but localhost can resolve to ::1.
|
|
if (config.launchAutomation) {
|
|
routes.push([
|
|
AUTOMATION_ROUTE_PREFIX,
|
|
`http://127.0.0.1:${config.autoBackendPort}`,
|
|
]);
|
|
}
|
|
|
|
if (config.launchAgentServer) {
|
|
for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) {
|
|
routes.push([prefix, getAgentServerBaseUrl(config)]);
|
|
}
|
|
|
|
// The editor is a separate process on its own port, but it is reached
|
|
// through the same origin as the canvas so no second port has to be
|
|
// published. The prefix is deliberately preserved rather than stripped:
|
|
// agent-server launches openvscode-server with `--server-base-path`, so
|
|
// the editor generates its own HTTP and WebSocket URLs beneath the prefix
|
|
// and only answers there. `createRouter` matches the longest prefix and
|
|
// the proxy forwards the original path, so both are already handled.
|
|
if (config.vscodeBasePath) {
|
|
routes.push([
|
|
config.vscodeBasePath,
|
|
`http://127.0.0.1:${config.vscodePort}`,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return routes;
|
|
}
|
|
|
|
function buildRouteArgs(routes) {
|
|
return routes.flatMap(([prefix, url]) => ["--route", `${prefix}=${url}`]);
|
|
}
|
|
|
|
/**
|
|
* The editor prefix, if this mode serves it, as `--no-referrer-prefix` args.
|
|
*
|
|
* agent-server hands the editor a connection token derived from its session
|
|
* key and advertises it in the URL's query string, so the workbench document
|
|
* must not leak a Referer to the subresources it loads.
|
|
*/
|
|
function getNoReferrerPrefixArgs(config) {
|
|
if (!config.launchAgentServer || !config.vscodeBasePath) return [];
|
|
return ["--no-referrer-prefix", config.vscodeBasePath];
|
|
}
|
|
|
|
/**
|
|
* The editor prefix, if this mode serves it, as `--vscode-base-path` args.
|
|
*
|
|
* Gated on exactly the same condition as the editor route in
|
|
* `getLocalServiceRoutes`, because they answer the same question: an origin
|
|
* advertises the editor if and only if it routes it. static-server enforces
|
|
* that pairing at startup, so a future edit that breaks it fails loudly rather
|
|
* than shipping a control that opens the SPA.
|
|
*/
|
|
function getVSCodeAdvertiseArgs(config) {
|
|
if (!config.launchAgentServer || !config.vscodeBasePath) return [];
|
|
return ["--vscode-base-path", config.vscodeBasePath];
|
|
}
|
|
|
|
/**
|
|
* Build --reject-prefix args for the static server.
|
|
* In frontend-only mode, API paths that have no backend should return 503
|
|
* instead of being SPA-fallbacked to index.html.
|
|
*/
|
|
function getRejectPrefixes(config) {
|
|
const prefixes = [];
|
|
if (!config.launchAutomation) {
|
|
prefixes.push(AUTOMATION_ROUTE_PREFIX);
|
|
}
|
|
if (!config.launchAgentServer) {
|
|
for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) {
|
|
prefixes.push(prefix);
|
|
}
|
|
// No agent-server means no editor behind this prefix either. Reject it
|
|
// rather than SPA-fallbacking to index.html, which would answer an editor
|
|
// request with the canvas shell.
|
|
if (config.vscodeBasePath) {
|
|
prefixes.push(config.vscodeBasePath);
|
|
}
|
|
}
|
|
return prefixes;
|
|
}
|
|
|
|
function buildRejectPrefixArgs(prefixes) {
|
|
return prefixes.flatMap((prefix) => ["--reject-prefix", prefix]);
|
|
}
|
|
|
|
function getFrontendBackend(config) {
|
|
return config.launchFrontend ? `http://localhost:${config.vitePort}` : null;
|
|
}
|
|
|
|
function buildViteBackendEnv(config, env = process.env) {
|
|
// VITE_BACKEND_HOST tells the Vite dev-server proxy (vite.config.ts) where
|
|
// to forward /api, /sockets, etc. It is NOT read by the frontend at
|
|
// runtime, so it is safe to keep as an absolute address.
|
|
//
|
|
// VITE_BACKEND_BASE_URL is intentionally left unset so the frontend falls
|
|
// back to window.location.origin (same-origin) at runtime — matching the
|
|
// behaviour of dev:static / agent-canvas and keeping the dev server
|
|
// portable across localhost, LAN hosts, SSH tunnels, and ngrok.
|
|
const backendHost = config.launchAgentServer
|
|
? `127.0.0.1:${config.ingressPort}`
|
|
: (env.VITE_BACKEND_HOST ??
|
|
env.VITE_BACKEND_BASE_URL?.replace(/^https?:\/\//, "") ??
|
|
"127.0.0.1:8000");
|
|
|
|
const env_out = { VITE_BACKEND_HOST: backendHost };
|
|
|
|
// If the user supplied VITE_BACKEND_BASE_URL with an https:// scheme and
|
|
// did not explicitly set VITE_USE_TLS, propagate the HTTPS intent so the
|
|
// Vite proxy forwards over TLS instead of plain HTTP.
|
|
if (
|
|
!config.launchAgentServer &&
|
|
env.VITE_BACKEND_BASE_URL?.startsWith("https://") &&
|
|
env.VITE_USE_TLS === undefined
|
|
) {
|
|
env_out.VITE_USE_TLS = "true";
|
|
}
|
|
|
|
return env_out;
|
|
}
|
|
|
|
function buildAgentServerAutomationEnv(config) {
|
|
return {
|
|
// Make the session API key available to terminal commands spawned by the
|
|
// agent-server as OPENHANDS_AUTOMATION_API_KEY. The launcher also seeds
|
|
// this into Settings > Secrets, but agents commonly create automations
|
|
// with a curl command that references `$OPENHANDS_AUTOMATION_API_KEY`;
|
|
// exposing it here keeps that path working even before/without
|
|
// secret-registry env expansion.
|
|
OPENHANDS_AUTOMATION_API_KEY: config.sessionApiKey,
|
|
};
|
|
}
|
|
|
|
function buildAutomationTelemetryEnv(env = process.env) {
|
|
const telemetryDisabled = env.VITE_DO_NOT_TRACK === "1";
|
|
const apiKey =
|
|
env.AUTOMATION_POSTHOG_API_KEY ||
|
|
env.VITE_POSTHOG_API_KEY ||
|
|
(telemetryDisabled ? "" : DEFAULT_POSTHOG_API_KEY);
|
|
|
|
if (!apiKey) return {};
|
|
|
|
return {
|
|
AUTOMATION_POSTHOG_API_KEY: apiKey,
|
|
AUTOMATION_POSTHOG_HOST:
|
|
env.AUTOMATION_POSTHOG_HOST ||
|
|
env.VITE_POSTHOG_HOST ||
|
|
DEFAULT_POSTHOG_HOST,
|
|
};
|
|
}
|
|
|
|
function startAgentServer(config) {
|
|
logService(
|
|
"agent-server",
|
|
`Starting on port ${config.agentServerPort}...`,
|
|
c.blue,
|
|
);
|
|
|
|
const agentServerCmd = buildAgentServerCommand(process.env);
|
|
logService("agent-server", `Using ${agentServerCmd.source}`, c.dim);
|
|
|
|
// Build safe config for agent-server env vars
|
|
const safeConfig = buildSafeDevConfig(config.canvasPath, {
|
|
...process.env,
|
|
OH_CANVAS_SAFE_STATE_DIR: config.stateDir,
|
|
OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(),
|
|
OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(),
|
|
});
|
|
|
|
const agentServerEnv = {
|
|
// Opt into prefix-mode: `getLocalServiceRoutes` registers the matching
|
|
// route on both the static server and the ingress, so the prefix this
|
|
// advertises resolves to the editor port on the canvas origin.
|
|
...buildAgentServerEnv(safeConfig, {
|
|
vscodeBasePath: config.vscodeBasePath,
|
|
}),
|
|
...buildAgentServerAutomationEnv(config),
|
|
OPENHANDS_REMOTE_WS_READY_REQUIRED:
|
|
process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false",
|
|
// Ensure the agent-server uses the resolved key from config. This is
|
|
// LOCAL_BACKEND_API_KEY when set, or the auto-generated persisted key.
|
|
OH_SESSION_API_KEYS_0: config.sessionApiKey,
|
|
// Emit structured JSON log lines instead of Rich-formatted output.
|
|
// Rich wraps long messages across multiple lines and prepends its own
|
|
// timestamp; LOG_JSON=true produces one JSON object per record which
|
|
// parseAgentServerLogLine re-formats into a clean single-line entry.
|
|
LOG_JSON: "true",
|
|
};
|
|
|
|
spawnService(
|
|
"agent-server",
|
|
agentServerCmd.command,
|
|
[
|
|
...agentServerCmd.args,
|
|
"--host",
|
|
"127.0.0.1",
|
|
"--port",
|
|
String(config.agentServerPort),
|
|
],
|
|
{
|
|
cwd: safeConfig.workspacesPath,
|
|
env: agentServerEnv,
|
|
color: c.blue,
|
|
parseLogLine: parseAgentServerLogLine,
|
|
},
|
|
);
|
|
}
|
|
|
|
function startAutomationBackend(config) {
|
|
logService(
|
|
"automation",
|
|
`Starting on port ${config.autoBackendPort}...`,
|
|
c.green,
|
|
);
|
|
|
|
const automationCmd = buildAutomationCommand(process.env);
|
|
logService("automation", `Using ${automationCmd.source}`, c.dim);
|
|
|
|
spawnService(
|
|
"automation",
|
|
automationCmd.command,
|
|
[
|
|
...automationCmd.args,
|
|
"--host",
|
|
"127.0.0.1",
|
|
"--port",
|
|
config.autoBackendPort.toString(),
|
|
],
|
|
{
|
|
cwd: config.stateDir,
|
|
env: {
|
|
// Force UTF-8 for all Python file I/O (same reason as agent-server;
|
|
// see buildAgentServerEnv in dev-safe.mjs).
|
|
PYTHONUTF8: "1",
|
|
OPENHANDS_REMOTE_WS_READY_REQUIRED:
|
|
process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false",
|
|
// The URL the automation backend itself uses to call the
|
|
// agent-server's REST API (tarball upload + bash dispatch).
|
|
//
|
|
// Priority:
|
|
// 1. AUTOMATION_AGENT_SERVER_URL explicitly set in the user's env
|
|
// 2. `127.0.0.1:<agentServerPort>`
|
|
AUTOMATION_AGENT_SERVER_URL:
|
|
process.env.AUTOMATION_AGENT_SERVER_URL ||
|
|
getAgentServerBaseUrl(config),
|
|
// The URL exported into the in-sandbox bash chain as
|
|
// `AGENT_SERVER_URL` (read by main.py / setup.sh to call back into
|
|
// the agent-server).
|
|
//
|
|
// Priority:
|
|
// 1. AUTOMATION_SANDBOX_AGENT_SERVER_URL explicitly set in env
|
|
// 2. launcher-provided value
|
|
// 3. unset — backend falls back to AUTOMATION_AGENT_SERVER_URL
|
|
...(process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL ||
|
|
config.sandboxAgentServerUrl
|
|
? {
|
|
AUTOMATION_SANDBOX_AGENT_SERVER_URL:
|
|
process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL ||
|
|
config.sandboxAgentServerUrl,
|
|
}
|
|
: {}),
|
|
AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey,
|
|
// ~/.openhands/automation/automations.db — matches docker/entrypoint.sh.
|
|
AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb)}`,
|
|
// The automation backend uses this as its publicly-reachable base
|
|
// URL: it's appended to callback URLs and injected into each
|
|
// sandbox as `AUTOMATION_API_URL` (consumed by setup.sh for
|
|
// /sdk-version and by the SDK for run completion).
|
|
// Priority:
|
|
// 1. AUTOMATION_BASE_URL explicitly set in the user's env
|
|
// 2. launcher-provided host
|
|
// 3. `localhost`
|
|
AUTOMATION_BASE_URL:
|
|
process.env.AUTOMATION_BASE_URL ||
|
|
`http://${config.automationApiHost ?? "localhost"}:${config.ingressPort}`,
|
|
// The dispatcher resolves this path and embeds it into a
|
|
// `mkdir -p ...` shell command executed by the agent-server.
|
|
// Priority:
|
|
// 1. AUTOMATION_WORKSPACE_BASE explicitly set in the user's env
|
|
// 2. `automationWorkspaceBase` option passed by the launcher
|
|
// 3. host-side default under config.stateDir
|
|
AUTOMATION_WORKSPACE_BASE:
|
|
process.env.AUTOMATION_WORKSPACE_BASE ||
|
|
config.automationWorkspaceBase ||
|
|
join(config.stateDir, "workspaces"),
|
|
// Session API key for self-hosted auth — shared with agent-server via X-Session-API-Key header
|
|
AUTOMATION_LOCAL_API_KEY: config.sessionApiKey,
|
|
...buildAutomationTelemetryEnv(),
|
|
// KV store secret — required for automations to use the built-in
|
|
// key-value store for state persistence between runs. Used for JWT
|
|
// signing and value encryption.
|
|
// Priority:
|
|
// 1. AUTOMATION_KV_SECRET explicitly set in the user's env
|
|
// 2. sessionApiKey — convenient zero-config default for local dev
|
|
AUTOMATION_KV_SECRET:
|
|
process.env.AUTOMATION_KV_SECRET || config.sessionApiKey,
|
|
// CORS: allow localhost origins for dev, unless explicitly overridden.
|
|
AUTOMATION_CORS_ORIGINS:
|
|
process.env.AUTOMATION_CORS_ORIGINS ||
|
|
`http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`,
|
|
FILE_STORE: "local",
|
|
LOCAL_STORAGE_PATH: join(config.stateDir, "storage"),
|
|
OPENHANDS_SUPPRESS_BANNER: "1",
|
|
},
|
|
color: c.green,
|
|
},
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Main
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
let shuttingDown = false;
|
|
|
|
function shutdown() {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
|
|
console.log("");
|
|
console.log(`${c.yellow}Shutting down...${c.reset}`);
|
|
fileLog("info", "Shutting down...");
|
|
|
|
for (const [name, proc] of processes) {
|
|
logService(name, "Stopping...", c.dim);
|
|
signalProcessTree(proc, "SIGTERM");
|
|
}
|
|
|
|
setTimeout(() => {
|
|
for (const [name, proc] of processes) {
|
|
if (isProcessRunning(proc)) {
|
|
logService(name, "Force stopping...", c.dim);
|
|
signalProcessTree(proc, "SIGKILL");
|
|
}
|
|
}
|
|
shutdownHooks.run();
|
|
process.exit(0);
|
|
}, 3000);
|
|
}
|
|
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|
|
process.on("SIGHUP", shutdown);
|
|
|
|
function startIngress(config) {
|
|
logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow);
|
|
|
|
const ingressScript = join(projectRoot, "scripts", "ingress.mjs");
|
|
const frontendBackend = getFrontendBackend(config);
|
|
const runtimeServicesInfo = config.launchAgentServer
|
|
? JSON.stringify(buildAutomationRuntimeServicesInfo(config))
|
|
: null;
|
|
|
|
spawnService(
|
|
"ingress",
|
|
"node",
|
|
[
|
|
ingressScript,
|
|
"--port",
|
|
config.ingressPort.toString(),
|
|
...(runtimeServicesInfo
|
|
? ["--runtime-services-info", runtimeServicesInfo]
|
|
: []),
|
|
...buildRouteArgs(getLocalServiceRoutes(config)),
|
|
...getNoReferrerPrefixArgs(config),
|
|
...(frontendBackend ? ["--default", frontendBackend] : []),
|
|
],
|
|
{
|
|
cwd: projectRoot,
|
|
color: c.yellow,
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build the JSON-serializable runtime services info for an automation
|
|
* stack. Backend-serving processes append this to `/server_info` so any
|
|
* frontend connected to the backend can populate the agent's
|
|
* `<RUNTIME_SERVICES>` system-prompt block.
|
|
*/
|
|
export function buildAutomationRuntimeServicesInfo(config, env = process.env) {
|
|
const dockerConversationRuntime = env.OH_CONVERSATION_RUNTIME === "docker";
|
|
const agentHostAlias = config.agentHostAlias ?? getAgentHostAlias(env);
|
|
return buildRuntimeServicesInfo({
|
|
mode: config.mode ?? "dev:automation",
|
|
agentHostAlias,
|
|
agentServerPort: config.agentServerPort,
|
|
ingressPort: config.ingressPort,
|
|
frontendPort: config.launchFrontend ? config.vitePort : undefined,
|
|
// The same port hosts Vite in dynamic mode and a static-file server
|
|
// in static mode. The launcher records this on the config so the
|
|
// description shown to the agent matches reality.
|
|
frontendKind: config.frontendKind ?? "vite",
|
|
automation: config.launchAutomation
|
|
? dockerConversationRuntime
|
|
? { url: `http://${agentHostAlias}:${config.ingressPort}` }
|
|
: { port: config.autoBackendPort }
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
export function getAgentHostAlias(env = process.env) {
|
|
return env.OH_CONVERSATION_RUNTIME === "docker"
|
|
? "host.docker.internal"
|
|
: "localhost";
|
|
}
|
|
|
|
function startVite(config) {
|
|
logService("vite", `Starting on port ${config.vitePort}...`, c.magenta);
|
|
|
|
const frontendCommand = buildNpmScriptCommand("dev:frontend");
|
|
|
|
const viteEnv = {
|
|
// Full-stack mode points Vite at this launcher's ingress. Frontend-only
|
|
// mode uses the separately running backend ingress instead.
|
|
...buildViteBackendEnv(config),
|
|
VITE_FRONTEND_PORT: config.vitePort.toString(),
|
|
};
|
|
if (config.viteWorkingDir) {
|
|
viteEnv.VITE_WORKING_DIR = config.viteWorkingDir;
|
|
}
|
|
|
|
// Vite serves the HTML for this mode's browser origin, so this is where the
|
|
// editor-capability advertisement has to be baked. The ingress in front of it
|
|
// routes the prefix but is a pure proxy — it injects nothing into the
|
|
// document, so it cannot tell the frontend what it serves.
|
|
//
|
|
// Both variables or neither: `vite.config.ts` only registers the editor proxy
|
|
// when it has a target as well as a prefix, and this stack has two supported
|
|
// browser origins — the ingress and Vite's own port, which is why the latter
|
|
// is in AUTOMATION_CORS_ORIGINS. On the ingress the prefix is routed by the
|
|
// ingress itself; on the Vite origin only this proxy can serve it. Baking the
|
|
// prefix alone would advertise an editor on the Vite origin whose URL then
|
|
// falls through to the SPA — the dead button this gating exists to prevent.
|
|
if (config.launchAgentServer && config.vscodeBasePath) {
|
|
viteEnv.VITE_VSCODE_BASE_PATH = config.vscodeBasePath;
|
|
viteEnv.VITE_VSCODE_TARGET = `http://127.0.0.1:${config.vscodePort}`;
|
|
}
|
|
|
|
// In local mode, bake the session key into the frontend so the user
|
|
// never has to paste it. In public mode, omit the key and set
|
|
// VITE_AUTH_REQUIRED so the frontend shows the API key entry screen
|
|
// immediately (no network round-trip needed).
|
|
if (config.launchAgentServer && config.isPublic) {
|
|
viteEnv.VITE_AUTH_REQUIRED = "true";
|
|
} else if (config.launchAgentServer) {
|
|
viteEnv.VITE_SESSION_API_KEY = config.sessionApiKey;
|
|
}
|
|
|
|
spawnService("vite", frontendCommand.command, frontendCommand.args, {
|
|
cwd: config.canvasPath,
|
|
env: viteEnv,
|
|
color: c.magenta,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Seed the session API key into agent-server's secrets store as
|
|
* OPENHANDS_AUTOMATION_API_KEY so agents can authenticate with the
|
|
* automation backend in curl commands during conversations.
|
|
*
|
|
* Includes retry logic to handle slow server startup or transient failures.
|
|
*
|
|
* @param {object} config - Configuration object with agentServerPort, sessionApiKey
|
|
* @param {object} options - Options for retry behavior
|
|
* @param {number} options.maxRetries - Maximum number of retry attempts (default: 5)
|
|
* @param {number} options.retryDelayMs - Delay between retries in ms (default: 2000)
|
|
* @param {number} options.timeoutMs - Request timeout in ms (default: 10000)
|
|
* @returns {Promise<boolean>} True if seeding succeeded, false otherwise
|
|
*/
|
|
async function seedAutomationSecret(config, options = {}) {
|
|
const { maxRetries = 5, retryDelayMs = 2000, timeoutMs = 10000 } = options;
|
|
|
|
const secretName = "OPENHANDS_AUTOMATION_API_KEY";
|
|
const secretDescription =
|
|
"API key for authenticating with the automation backend";
|
|
|
|
logService("secrets", `Seeding ${secretName} into agent-server...`, c.dim);
|
|
|
|
const url = `${getAgentServerBaseUrl(config)}/api/settings/secrets`;
|
|
const body = JSON.stringify({
|
|
name: secretName,
|
|
value: config.sessionApiKey,
|
|
description: secretDescription,
|
|
});
|
|
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
// Include session API key if configured
|
|
...(config.sessionApiKey && { "X-Session-API-Key": config.sessionApiKey }),
|
|
};
|
|
|
|
let lastError = null;
|
|
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "PUT",
|
|
headers,
|
|
body,
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
|
|
if (response.ok) {
|
|
logService("secrets", `${secretName} seeded successfully`, c.green);
|
|
return true;
|
|
}
|
|
|
|
const text = await response.text();
|
|
lastError = `HTTP ${response.status}: ${text}`;
|
|
|
|
// Don't retry on authentication errors - they won't resolve with retries
|
|
if (response.status === 401 || response.status === 403) {
|
|
logService(
|
|
"secrets",
|
|
`Warning: Failed to seed secret (${response.status}): ${text}`,
|
|
c.yellow,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// Retry on server errors or service unavailable
|
|
if (attempt < maxRetries) {
|
|
logService(
|
|
"secrets",
|
|
`Retry ${attempt}/${maxRetries} after ${response.status}...`,
|
|
c.dim,
|
|
);
|
|
await delay(retryDelayMs);
|
|
}
|
|
} catch (err) {
|
|
lastError = err.message;
|
|
|
|
// Connection errors likely mean server isn't ready - wait and retry
|
|
if (attempt < maxRetries) {
|
|
logService(
|
|
"secrets",
|
|
`Retry ${attempt}/${maxRetries}: ${err.message}`,
|
|
c.dim,
|
|
);
|
|
await delay(retryDelayMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
logService(
|
|
"secrets",
|
|
`Warning: Failed to seed secret after ${maxRetries} attempts: ${lastError}`,
|
|
c.yellow,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
function printBanner(config) {
|
|
const stackName = config.frontendOnly
|
|
? "Agent Canvas Frontend Stack"
|
|
: config.backendOnly
|
|
? "Agent Canvas Backend Stack"
|
|
: "Agent Canvas + Automation Stack";
|
|
|
|
// padEnd counts invisible ANSI escape bytes as visible characters, so we
|
|
// compute the visible length separately and pad with spaces accordingly.
|
|
const ansiEscape = String.fromCharCode(27);
|
|
const ansiRe = new RegExp(`${ansiEscape}\\[[0-9;]*m`, "g");
|
|
const ansiPadEnd = (str, targetVisible) => {
|
|
const visible = str.replace(ansiRe, "").length;
|
|
return str + " ".repeat(Math.max(0, targetVisible - visible));
|
|
};
|
|
// The box has 62-char inner width; each content line needs 63 visible chars
|
|
// before the trailing border (1 leading ║ + 62 inner).
|
|
const BOX_INNER = 63;
|
|
|
|
console.log("");
|
|
console.log(
|
|
`${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`,
|
|
);
|
|
console.log(
|
|
ansiPadEnd(
|
|
`${c.green}${c.bold}║${c.reset} ${c.bold}${stackName}${c.reset}`,
|
|
BOX_INNER,
|
|
) + `${c.green}${c.bold}║${c.reset}`,
|
|
);
|
|
console.log(
|
|
`${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`,
|
|
);
|
|
console.log(
|
|
`${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`,
|
|
);
|
|
console.log(
|
|
ansiPadEnd(
|
|
`${c.green}${c.bold}║${c.reset} Ingress: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`,
|
|
BOX_INNER,
|
|
) + `${c.green}${c.bold}║${c.reset}`,
|
|
);
|
|
if (config.launchFrontend) {
|
|
console.log(
|
|
ansiPadEnd(
|
|
`${c.green}${c.bold}║${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`,
|
|
BOX_INNER,
|
|
) + `${c.green}${c.bold}║${c.reset}`,
|
|
);
|
|
}
|
|
if (config.launchAutomation) {
|
|
console.log(
|
|
ansiPadEnd(
|
|
`${c.green}${c.bold}║${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`,
|
|
BOX_INNER,
|
|
) + `${c.green}${c.bold}║${c.reset}`,
|
|
);
|
|
}
|
|
console.log(
|
|
`${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`,
|
|
);
|
|
console.log(
|
|
`${c.green}${c.bold}╚══════════════════════════════════════════════════════════════╝${c.reset}`,
|
|
);
|
|
console.log("");
|
|
console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`);
|
|
console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`);
|
|
console.log("");
|
|
|
|
// Write a compact plain-text summary to the log file.
|
|
const summary = [
|
|
`${stackName} — started`,
|
|
` Ingress: http://localhost:${config.ingressPort}/`,
|
|
...(config.launchFrontend
|
|
? [` Main UI: http://localhost:${config.ingressPort}/`]
|
|
: []),
|
|
...(config.launchAutomation
|
|
? [
|
|
` API Docs: http://localhost:${config.ingressPort}/api/automation/docs`,
|
|
]
|
|
: []),
|
|
` State directory: ${config.stateDir}`,
|
|
];
|
|
fileLog("info", summary.join("\n"));
|
|
}
|
|
|
|
async function main(options = {}) {
|
|
const {
|
|
bannerTitle = "Agent Canvas + Automation Development Stack",
|
|
startAgentServer: startAgentServerOverride,
|
|
extraPrereqs,
|
|
viteWorkingDir,
|
|
// Path used as `AUTOMATION_WORKSPACE_BASE` by the automation backend.
|
|
// Defaults to a host-side path under config.stateDir.
|
|
automationWorkspaceBase,
|
|
// Host used in `AUTOMATION_BASE_URL` (the URL the automation sandbox
|
|
// uses to call back into the automation backend). Defaults to `localhost`.
|
|
automationApiHost,
|
|
// Value exported as `AUTOMATION_SANDBOX_AGENT_SERVER_URL` to the
|
|
// automation backend. This is the URL the in-sandbox bash chain uses
|
|
// to reach the agent-server. When unset the backend falls back to
|
|
// AUTOMATION_AGENT_SERVER_URL.
|
|
sandboxAgentServerUrl,
|
|
staticMode: staticModeOverride,
|
|
defaultStaticMode = false,
|
|
buildStaticFrontend,
|
|
staticDir: staticDirOverride,
|
|
// Hostname the agent uses to reach services running on the host.
|
|
agentHostAlias = getAgentHostAlias(),
|
|
// Human-readable label for the dev mode, surfaced in the agent's
|
|
// <RUNTIME_SERVICES> system-prompt block.
|
|
mode = "dev:automation",
|
|
// When true, enable public mode (require LOCAL_BACKEND_API_KEY,
|
|
// don't bake session key into frontend).
|
|
isPublic: isPublicOverride,
|
|
// When true, skip the npm prerequisite check. Used by the Electron desktop
|
|
// launcher where npm is not needed at runtime in static mode.
|
|
skipNpmCheck = false,
|
|
// How long to wait for the agent-server's `/server_info` to return 200
|
|
// before continuing. Defaults to 60 s, which is fine for warm-cache dev
|
|
// workflows. The Electron desktop launcher bumps this to several minutes
|
|
// because first-launch on a fresh machine runs `uvx` to download Python
|
|
// and install `openhands-agent-server` from PyPI, which can take much
|
|
// longer than 60 s on a slow network.
|
|
agentServerReadyTimeoutMs = 60_000,
|
|
// Optional `(name, line, level)` callback that receives every service log
|
|
// line (stdout, stderr, and lifecycle events) emitted by any spawned
|
|
// backend process. Used by the Electron loading screen to surface uvx
|
|
// download / install progress to the user. `level` is one of
|
|
// "stdout" | "stderr" | "info" | "warn" | "error".
|
|
onServiceLog,
|
|
} = options;
|
|
|
|
// Install the listener early so log lines emitted before the first
|
|
// `spawnService` call (e.g. by future setup steps) are also captured.
|
|
setServiceLogListener(onServiceLog);
|
|
|
|
const args = parseArgs();
|
|
|
|
// Allow options to override CLI args for public mode
|
|
if (isPublicOverride != null) {
|
|
args.public = isPublicOverride;
|
|
}
|
|
|
|
// Allow options to override CLI args (for bin/agent-canvas.mjs)
|
|
const useStaticMode =
|
|
staticModeOverride ??
|
|
(args.dynamic ? false : args.static || defaultStaticMode);
|
|
const staticDir =
|
|
staticDirOverride ?? args.staticDir ?? join(projectRoot, "build");
|
|
|
|
const modeLabel = useStaticMode && !args.backendOnly ? "(Static)" : "";
|
|
const titleWithMode = modeLabel ? `${bannerTitle} ${modeLabel}` : bannerTitle;
|
|
|
|
console.log("");
|
|
console.log(`${c.cyan}${c.bold}${titleWithMode}${c.reset}`);
|
|
console.log("");
|
|
fileLog("info", titleWithMode);
|
|
|
|
// Setup phase
|
|
checkPrerequisites({
|
|
checkUvx: !args.frontendOnly,
|
|
// Static-mode + backend-only has no frontend to build, so npm is not
|
|
// required — unless the caller provides a custom buildStaticFrontend hook.
|
|
// The Electron desktop launcher passes `skipNpmCheck: true` because the
|
|
// packaged binary serves a pre-built static frontend and never invokes
|
|
// npm at runtime, so we suppress the check unconditionally there.
|
|
checkNpm:
|
|
!skipNpmCheck &&
|
|
((!useStaticMode && !args.backendOnly) ||
|
|
typeof buildStaticFrontend === "function"),
|
|
checkFrontendDependencies:
|
|
(!useStaticMode && !args.backendOnly) ||
|
|
typeof buildStaticFrontend === "function",
|
|
});
|
|
|
|
// Fail fast on an obviously bad OH_AGENT_SERVER_LOCAL_PATH so we don't waste
|
|
// time allocating ports / generating keys / launching uvx with a path that
|
|
// would only produce a cryptic build error. Mirrors dev-safe.mjs and
|
|
// dev-extra-backend.mjs.
|
|
if (!args.frontendOnly && process.env.OH_AGENT_SERVER_LOCAL_PATH) {
|
|
try {
|
|
validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
|
|
} catch (error) {
|
|
logError(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Same for the automation checkout -- skipped when --automation-git-ref was
|
|
// passed, since buildConfig drops the env var in favor of the explicit flag.
|
|
if (
|
|
!args.frontendOnly &&
|
|
!args.automationGitRef &&
|
|
process.env.OH_AUTOMATION_LOCAL_PATH
|
|
) {
|
|
try {
|
|
validateLocalAutomationPath(process.env.OH_AUTOMATION_LOCAL_PATH);
|
|
} catch (error) {
|
|
logError(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Build config with dynamic port allocation
|
|
const config = await buildConfig(args);
|
|
if (viteWorkingDir) config.viteWorkingDir = viteWorkingDir;
|
|
if (automationWorkspaceBase) {
|
|
config.automationWorkspaceBase = automationWorkspaceBase;
|
|
}
|
|
if (automationApiHost) {
|
|
config.automationApiHost = automationApiHost;
|
|
}
|
|
if (sandboxAgentServerUrl) {
|
|
config.sandboxAgentServerUrl = sandboxAgentServerUrl;
|
|
}
|
|
// Stamp the dev-mode label, host alias, and frontend kind on the config
|
|
// so downstream helpers (Vite spawn, static build) can produce a
|
|
// runtime-services info object describing what the agent can reach.
|
|
config.mode = mode;
|
|
config.agentHostAlias = agentHostAlias;
|
|
config.frontendKind = useStaticMode ? "static" : "vite";
|
|
ensureDirectories(config);
|
|
if (typeof extraPrereqs === "function") {
|
|
extraPrereqs(config);
|
|
}
|
|
|
|
if (
|
|
config.launchFrontend &&
|
|
useStaticMode &&
|
|
typeof buildStaticFrontend === "function"
|
|
) {
|
|
buildStaticFrontend(config, args);
|
|
}
|
|
|
|
// In static mode, verify build exists after any launcher-managed build.
|
|
if (config.launchFrontend && useStaticMode && !existsSync(staticDir)) {
|
|
logError(`Static directory not found: ${staticDir}`);
|
|
logError(`Run 'npm run build' first to create the static files.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Start services phase
|
|
logStep("2/2", "Starting services...");
|
|
|
|
let agentServerReady = false;
|
|
|
|
// 1. Start agent-server first (automation depends on it).
|
|
//
|
|
// Readiness timeout defaults to 60 s, which is fine for `npm run dev` against
|
|
// a warm uvx cache. The Electron desktop launcher overrides this via the
|
|
// `agentServerReadyTimeoutMs` option because first-launch on a fresh machine
|
|
// runs `uvx` to download Python + install `openhands-agent-server` from PyPI,
|
|
// which can take several minutes. Dropping the user into a half-booted UI
|
|
// before that completes triggers axios "Request timeout" popups on the first
|
|
// SPA fetch that hits an unbound port 18000.
|
|
if (config.launchAgentServer) {
|
|
const agentServerStarter = startAgentServerOverride ?? startAgentServer;
|
|
agentServerStarter(config);
|
|
|
|
agentServerReady = await waitForService(
|
|
"agent-server",
|
|
`${getAgentServerBaseUrl(config)}/server_info`,
|
|
agentServerReadyTimeoutMs,
|
|
);
|
|
}
|
|
|
|
// 2. Seed automation API key into agent-server secrets
|
|
// This makes the key available to agents during conversations
|
|
// Note: seedAutomationSecret has its own retry logic if server is still warming up
|
|
if (config.launchAutomation && agentServerReady) {
|
|
await seedAutomationSecret(config);
|
|
} else if (config.launchAutomation) {
|
|
logService(
|
|
"secrets",
|
|
"Skipping secret seeding - agent-server not ready",
|
|
c.yellow,
|
|
);
|
|
}
|
|
|
|
// 3. Start automation backend
|
|
if (config.launchAutomation) {
|
|
startAutomationBackend(config);
|
|
}
|
|
|
|
// 4. Start frontend server (Vite dev server OR static server)
|
|
if (config.launchFrontend) {
|
|
if (useStaticMode) {
|
|
startStaticFrontend(config, staticDir);
|
|
} else {
|
|
startVite(config);
|
|
}
|
|
}
|
|
|
|
// 5. Wait for services to be ready
|
|
await delay(2000);
|
|
|
|
// 6. Start ingress proxy (routes traffic only to running services)
|
|
startIngress(config);
|
|
|
|
// Wait for ingress to start
|
|
await delay(1000);
|
|
|
|
printBanner(config);
|
|
|
|
// Return the resolved config + readiness signal so embedded launchers can
|
|
// (a) build URLs from the actual allocated ports and (b) decide whether to
|
|
// show an error to the user when the agent-server never came up.
|
|
return { config, agentServerReady };
|
|
}
|
|
|
|
function startStaticFrontend(config, staticDir) {
|
|
logService("static", `Starting on port ${config.vitePort}...`, c.magenta);
|
|
logService("static", `Serving from: ${staticDir}`, c.dim);
|
|
|
|
// Build the runtime-services info JSON so static-server can append it to
|
|
// /server_info. The static-server also injects the old window global for
|
|
// compatibility with previously built frontend bundles.
|
|
const runtimeServicesInfo = config.launchAgentServer
|
|
? JSON.stringify(buildAutomationRuntimeServicesInfo(config))
|
|
: null;
|
|
|
|
const staticServerScript = join(projectRoot, "scripts", "static-server.mjs");
|
|
spawnService(
|
|
"static",
|
|
"node",
|
|
[
|
|
staticServerScript,
|
|
"--dir",
|
|
staticDir,
|
|
"--port",
|
|
String(config.vitePort),
|
|
...(process.env.VITE_BASE_PATH
|
|
? ["--base-path", process.env.VITE_BASE_PATH]
|
|
: []),
|
|
// In local mode, inject the API key so the pre-built frontend can
|
|
// authenticate transparently. In public mode, pass --auth-required
|
|
// so the frontend shows the API key entry screen instead.
|
|
...(config.launchAgentServer && !config.isPublic && config.sessionApiKey
|
|
? ["--session-api-key", config.sessionApiKey]
|
|
: []),
|
|
...(config.launchAgentServer && config.isPublic
|
|
? ["--auth-required"]
|
|
: []),
|
|
// Inject runtime-services info so the agent knows what's reachable.
|
|
...(runtimeServicesInfo
|
|
? ["--runtime-services-info", runtimeServicesInfo]
|
|
: []),
|
|
// Proxy routes only to services that this launch mode started.
|
|
...buildRouteArgs(getLocalServiceRoutes(config)),
|
|
// Only the static server injects into the document, so only it can tell
|
|
// the frontend this origin serves the editor. The ingress routes the same
|
|
// prefix but proxies the HTML through untouched.
|
|
...getVSCodeAdvertiseArgs(config),
|
|
...getNoReferrerPrefixArgs(config),
|
|
// Reject known API prefixes that have no backend — returns 503
|
|
// instead of SPA-fallbacking to index.html.
|
|
...buildRejectPrefixArgs(getRejectPrefixes(config)),
|
|
],
|
|
{
|
|
cwd: config.canvasPath,
|
|
color: c.magenta,
|
|
},
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Exports for testing
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
export {
|
|
buildAgentServerAutomationEnv,
|
|
buildAutomationCommand,
|
|
buildAutomationTelemetryEnv,
|
|
buildConfig,
|
|
buildRouteArgs,
|
|
buildViteBackendEnv,
|
|
getAgentServerBaseUrl,
|
|
getFrontendBackend,
|
|
getLocalServiceRoutes,
|
|
getNoReferrerPrefixArgs,
|
|
getRejectPrefixes,
|
|
getVSCodeAdvertiseArgs,
|
|
main,
|
|
registerShutdownHook,
|
|
spawnService,
|
|
commandExists,
|
|
validateLocalAutomationPath,
|
|
logService,
|
|
logStep,
|
|
logSuccess,
|
|
logError,
|
|
c,
|
|
DEFAULT_AUTOMATION_REPO,
|
|
DEFAULT_AUTOMATION_PACKAGE,
|
|
DEFAULT_AUTOMATION_VERSION,
|
|
DEFAULT_AUTOMATION_SDK_VERSION,
|
|
DEFAULT_BACKEND_PORT,
|
|
DEFAULT_AUTOMATION_PORT,
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Main entry point (only when run directly, not when imported)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
// Check if this module is the main entry point
|
|
const isMainModule =
|
|
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
|
|
if (isMainModule) {
|
|
main().catch((err) => {
|
|
logError(`Fatal error: ${err.message}`);
|
|
if (err.stack) {
|
|
console.error(c.dim + err.stack + c.reset);
|
|
fileLog("error", err.stack);
|
|
}
|
|
process.exit(1);
|
|
});
|
|
}
|