Gateway: pre-build web app + Control UI before daemon install (2026.2.6-3.7)
The Next.js web app was only built inside the gateway process on first boot. When the daemon was freshly installed (e.g. `onboard --install-daemon`), the LaunchAgent would start and block on `next build`, causing a noticeably slow first startup. Add `ensureWebAppBuilt()` to `src/gateway/server-web-app.ts` — a standalone pre-build function that checks for `.next/BUILD_ID` and runs dep install + `next build` if missing. Skips silently when the web app is disabled, already built, in dev mode, or inapplicable (global npm install without `apps/web`). Call both `ensureWebAppBuilt()` and `ensureControlUiAssetsBuilt()` before the daemon is installed in every relevant path: - Interactive onboarding (`onboarding.finalize.ts`) — moved the existing Control UI build from after the daemon install to before it, and added the web app build alongside it. - Non-interactive onboarding (`daemon-install.ts`) — added both pre-build calls before `service.install()`. - Standalone `openclaw gateway install` CLI (`daemon-cli/install.ts`) — added both pre-build calls before `service.install()`. - Configure wizard (`configure.wizard.ts`) — added the web app build alongside the existing Control UI build. Updated test mocks for `ensureWebAppBuilt` in onboarding, configure wizard, and daemon CLI coverage tests. Bumped version to 2026.2.6-3.7 and published to npm. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a03f1d5ea4
commit
e865265f0f
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ironclaw",
|
||||
"version": "2026.2.6-3.6",
|
||||
"version": "2026.2.6-3.7",
|
||||
"description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
|
||||
@ -62,6 +62,15 @@ vi.mock("../daemon/inspect.js", () => ({
|
||||
renderGatewayServiceCleanupHints: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/server-web-app.js", () => ({
|
||||
ensureWebAppBuilt: vi.fn(async () => ({ ok: true, built: false })),
|
||||
DEFAULT_WEB_APP_PORT: 3100,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/control-ui-assets.js", () => ({
|
||||
ensureControlUiAssetsBuilt: vi.fn(async () => ({ ok: true, built: false })),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/ports.js", () => ({
|
||||
inspectPortUsage: (port: number) => inspectPortUsage(port),
|
||||
formatPortDiagnostics: () => ["Port 18789 is already in use."],
|
||||
|
||||
@ -7,6 +7,8 @@ import {
|
||||
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
|
||||
import { resolveIsNixMode } from "../../config/paths.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { ensureWebAppBuilt } from "../../gateway/server-web-app.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../../infra/control-ui-assets.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatCliCommand } from "../command-format.js";
|
||||
import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js";
|
||||
@ -93,6 +95,27 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-build web app + Control UI assets so the gateway doesn't block on
|
||||
// builds when it boots for the first time after daemon install.
|
||||
const webAppResult = await ensureWebAppBuilt(defaultRuntime, {
|
||||
webAppConfig: cfg.gateway?.webApp,
|
||||
});
|
||||
if (!webAppResult.ok && webAppResult.message) {
|
||||
if (json) {
|
||||
warnings.push(webAppResult.message);
|
||||
} else {
|
||||
defaultRuntime.error(webAppResult.message);
|
||||
}
|
||||
}
|
||||
const controlUiResult = await ensureControlUiAssetsBuilt(defaultRuntime);
|
||||
if (!controlUiResult.ok && controlUiResult.message) {
|
||||
if (json) {
|
||||
warnings.push(controlUiResult.message);
|
||||
} else {
|
||||
defaultRuntime.error(controlUiResult.message);
|
||||
}
|
||||
}
|
||||
|
||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||
env: process.env,
|
||||
port,
|
||||
|
||||
@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
||||
writeConfigFile: vi.fn(),
|
||||
resolveGatewayPort: vi.fn(),
|
||||
ensureControlUiAssetsBuilt: vi.fn(),
|
||||
ensureWebAppBuilt: vi.fn(async () => ({ ok: true, built: false })),
|
||||
createClackPrompter: vi.fn(),
|
||||
note: vi.fn(),
|
||||
printWizardHeader: vi.fn(),
|
||||
@ -35,6 +36,10 @@ vi.mock("../config/config.js", () => ({
|
||||
resolveGatewayPort: mocks.resolveGatewayPort,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/server-web-app.js", () => ({
|
||||
ensureWebAppBuilt: mocks.ensureWebAppBuilt,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/control-ui-assets.js", () => ({
|
||||
ensureControlUiAssetsBuilt: mocks.ensureControlUiAssetsBuilt,
|
||||
}));
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import { ensureWebAppBuilt } from "../gateway/server-web-app.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
@ -591,6 +592,12 @@ export async function runConfigureWizard(
|
||||
}
|
||||
}
|
||||
|
||||
const webAppResult = await ensureWebAppBuilt(runtime, {
|
||||
webAppConfig: nextConfig.gateway?.webApp,
|
||||
});
|
||||
if (!webAppResult.ok && webAppResult.message) {
|
||||
runtime.error(webAppResult.message);
|
||||
}
|
||||
const controlUiAssets = await ensureControlUiAssetsBuilt(runtime);
|
||||
if (!controlUiAssets.ok && controlUiAssets.message) {
|
||||
runtime.error(controlUiAssets.message);
|
||||
|
||||
@ -3,6 +3,8 @@ import type { RuntimeEnv } from "../../../runtime.js";
|
||||
import type { OnboardOptions } from "../../onboard-types.js";
|
||||
import { resolveGatewayService } from "../../../daemon/service.js";
|
||||
import { isSystemdUserServiceAvailable } from "../../../daemon/systemd.js";
|
||||
import { ensureWebAppBuilt } from "../../../gateway/server-web-app.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../../../infra/control-ui-assets.js";
|
||||
import { buildGatewayInstallPlan, gatewayInstallErrorHint } from "../../daemon-install-helpers.js";
|
||||
import { DEFAULT_GATEWAY_DAEMON_RUNTIME, isGatewayDaemonRuntime } from "../../daemon-runtime.js";
|
||||
import { ensureSystemdUserLingerNonInteractive } from "../../systemd-linger.js";
|
||||
@ -33,6 +35,19 @@ export async function installGatewayDaemonNonInteractive(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-build web app + Control UI assets so the gateway doesn't block on
|
||||
// builds when it boots for the first time after daemon install.
|
||||
const webAppResult = await ensureWebAppBuilt(runtime, {
|
||||
webAppConfig: params.nextConfig.gateway?.webApp,
|
||||
});
|
||||
if (!webAppResult.ok && webAppResult.message) {
|
||||
runtime.error(webAppResult.message);
|
||||
}
|
||||
const controlUiResult = await ensureControlUiAssetsBuilt(runtime);
|
||||
if (!controlUiResult.ok && controlUiResult.message) {
|
||||
runtime.error(controlUiResult.message);
|
||||
}
|
||||
|
||||
const service = resolveGatewayService();
|
||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||
env: process.env,
|
||||
|
||||
@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { GatewayWebAppConfig } from "../config/types.gateway.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export const DEFAULT_WEB_APP_PORT = 3100;
|
||||
|
||||
@ -39,6 +40,75 @@ function hasNextBuild(webAppDir: string): boolean {
|
||||
return fs.existsSync(path.join(webAppDir, ".next", "BUILD_ID"));
|
||||
}
|
||||
|
||||
// ── pre-build ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type EnsureWebAppBuiltResult = {
|
||||
ok: boolean;
|
||||
built: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-build the Next.js web app so the gateway can start it immediately.
|
||||
*
|
||||
* Call this before installing/starting the gateway daemon so the first
|
||||
* gateway boot doesn't block on `next build`. Skips silently when the
|
||||
* web app feature is disabled, already built, or not applicable (e.g.
|
||||
* global npm install without `apps/web`).
|
||||
*/
|
||||
export async function ensureWebAppBuilt(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
opts?: { webAppConfig?: GatewayWebAppConfig },
|
||||
): Promise<EnsureWebAppBuiltResult> {
|
||||
if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_WEB_APP)) {
|
||||
return { ok: true, built: false };
|
||||
}
|
||||
if (opts?.webAppConfig && opts.webAppConfig.enabled === false) {
|
||||
return { ok: true, built: false };
|
||||
}
|
||||
// Dev mode uses `next dev` which compiles on-the-fly — no pre-build needed.
|
||||
if (opts?.webAppConfig?.dev) {
|
||||
return { ok: true, built: false };
|
||||
}
|
||||
|
||||
const webAppDir = resolveWebAppDir();
|
||||
if (!webAppDir) {
|
||||
// No apps/web directory (e.g. global install) — nothing to build.
|
||||
return { ok: true, built: false };
|
||||
}
|
||||
|
||||
if (hasNextBuild(webAppDir)) {
|
||||
return { ok: true, built: false };
|
||||
}
|
||||
|
||||
const log = {
|
||||
info: (msg: string) => runtime.log(msg),
|
||||
warn: (msg: string) => runtime.error(msg),
|
||||
};
|
||||
|
||||
try {
|
||||
await ensureDepsInstalled(webAppDir, log);
|
||||
runtime.log("Web app not built; building for production (next build)…");
|
||||
await runCommand("npx", ["next", "build"], webAppDir, log);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
built: false,
|
||||
message: `Web app pre-build failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasNextBuild(webAppDir)) {
|
||||
return {
|
||||
ok: false,
|
||||
built: true,
|
||||
message: "Web app build completed but .next/BUILD_ID is still missing.",
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, built: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Ironclaw Next.js web app as a child process.
|
||||
*
|
||||
|
||||
@ -33,7 +33,7 @@ import {
|
||||
} from "../commands/onboard-helpers.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
|
||||
import { DEFAULT_WEB_APP_PORT } from "../gateway/server-web-app.js";
|
||||
import { DEFAULT_WEB_APP_PORT, ensureWebAppBuilt } from "../gateway/server-web-app.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import { restoreTerminalState } from "../terminal/restore.js";
|
||||
import { runTui } from "../tui/tui.js";
|
||||
@ -91,6 +91,24 @@ export async function finalizeOnboardingWizard(
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-build web app + Control UI assets before daemon install so the
|
||||
// gateway doesn't block on builds when it boots for the first time.
|
||||
const controlUiEnabled =
|
||||
nextConfig.gateway?.controlUi?.enabled ?? baseConfig.gateway?.controlUi?.enabled ?? true;
|
||||
if (!opts.skipUi && controlUiEnabled) {
|
||||
const webAppResult = await ensureWebAppBuilt(runtime, {
|
||||
webAppConfig: nextConfig.gateway?.webApp,
|
||||
});
|
||||
if (!webAppResult.ok && webAppResult.message) {
|
||||
runtime.error(webAppResult.message);
|
||||
}
|
||||
|
||||
const controlUiAssets = await ensureControlUiAssetsBuilt(runtime);
|
||||
if (!controlUiAssets.ok && controlUiAssets.message) {
|
||||
runtime.error(controlUiAssets.message);
|
||||
}
|
||||
}
|
||||
|
||||
const explicitInstallDaemon =
|
||||
typeof opts.installDaemon === "boolean" ? opts.installDaemon : undefined;
|
||||
let installDaemon: boolean;
|
||||
@ -229,15 +247,6 @@ export async function finalizeOnboardingWizard(
|
||||
}
|
||||
}
|
||||
|
||||
const controlUiEnabled =
|
||||
nextConfig.gateway?.controlUi?.enabled ?? baseConfig.gateway?.controlUi?.enabled ?? true;
|
||||
if (!opts.skipUi && controlUiEnabled) {
|
||||
const controlUiAssets = await ensureControlUiAssetsBuilt(runtime);
|
||||
if (!controlUiAssets.ok && controlUiAssets.message) {
|
||||
runtime.error(controlUiAssets.message);
|
||||
}
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"Add nodes for extra features:",
|
||||
|
||||
@ -18,6 +18,7 @@ const readConfigFileSnapshot = vi.hoisted(() =>
|
||||
const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const isSystemdUserServiceAvailable = vi.hoisted(() => vi.fn(async () => true));
|
||||
const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
|
||||
const ensureWebAppBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: true, built: false })));
|
||||
const runTui = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("../commands/onboard-channels.js", () => ({
|
||||
@ -65,6 +66,11 @@ vi.mock("../daemon/systemd.js", () => ({
|
||||
isSystemdUserServiceAvailable,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/server-web-app.js", () => ({
|
||||
ensureWebAppBuilt,
|
||||
DEFAULT_WEB_APP_PORT: 3100,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/control-ui-assets.js", () => ({
|
||||
ensureControlUiAssetsBuilt,
|
||||
}));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user