diff --git a/package.json b/package.json index 66f4a46d794..325afb5ce34 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 59adbf74e9c..a9210d70ca4 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -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."], diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 1838d09a20d..6b30d613156 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -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, diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index a3f8ffd4d1e..a8e4d7ce77b 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -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, })); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 74eea0db26d..f24064b583d 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -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); diff --git a/src/commands/onboard-non-interactive/local/daemon-install.ts b/src/commands/onboard-non-interactive/local/daemon-install.ts index 984113226cc..34ba507607f 100644 --- a/src/commands/onboard-non-interactive/local/daemon-install.ts +++ b/src/commands/onboard-non-interactive/local/daemon-install.ts @@ -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, diff --git a/src/gateway/server-web-app.ts b/src/gateway/server-web-app.ts index 49da8ed635f..81eb7fc80f2 100644 --- a/src/gateway/server-web-app.ts +++ b/src/gateway/server-web-app.ts @@ -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 { + 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. * diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index 1287e45f22c..89d4c512d6a 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -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:", diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts index 937f7b33cbf..1c097a034aa 100644 --- a/src/wizard/onboarding.test.ts +++ b/src/wizard/onboarding.test.ts @@ -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, }));