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:
kumarabhirup 2026-02-12 01:32:18 -08:00
parent a03f1d5ea4
commit e865265f0f
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
9 changed files with 155 additions and 11 deletions

View File

@ -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",

View File

@ -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."],

View File

@ -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,

View File

@ -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,
}));

View File

@ -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);

View File

@ -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,

View File

@ -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.
*

View File

@ -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:",

View File

@ -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,
}));