Gateway: ship Ironclaw web app as a managed sidecar

- Add gateway.webApp config (enabled, port, dev) as the unified toggle
  for both the Next.js web UI and the built-in control UI
- Spawn Next.js app alongside the gateway; stop it on shutdown
- Auto-enable webApp in config for new and existing installs
- Pre-build Next.js in deploy.sh and ship .next/ in the npm package
  so installed users get instant startup (no build step)
- Gateway skips build when pre-built .next/ exists; builds on first
  run for dev/git-checkout users
- Onboarding "Open the Web UI" now opens the Ironclaw web app
- Fix pre-existing Next.js build errors (ES2023 lib, Tiptap v3 types,
  Suspense boundary, ReportConfig type alignment)
- Rename deploy target from openclaw-ai-sdk to ironclaw

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kumarabhirup 2026-02-12 00:29:39 -08:00
parent dfaf7180b4
commit a1a54403a5
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
21 changed files with 323 additions and 191 deletions

View File

@ -399,7 +399,7 @@ export function MarkdownEditor({
<EditorToolbar editor={editor} onUploadImages={insertUploadedImages} />
{/* Bubble menu for text selection */}
<BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
<BubbleMenu editor={editor}>
<div className="bubble-menu">
<BubbleButton
active={editor.isActive("bold")}

View File

@ -1,7 +1,7 @@
"use client";
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import { ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
import { useState, useCallback } from "react";
import type { ReportConfig } from "../../components/charts/types";
@ -30,20 +30,16 @@ function ReportBlockView({
updateAttributes,
deleteNode,
selected,
}: {
node: { attrs: { config: string } };
updateAttributes: (attrs: Record<string, unknown>) => void;
deleteNode: () => void;
selected: boolean;
}) {
}: ReactNodeViewProps) {
const configAttr = node.attrs.config as string;
const [showSource, setShowSource] = useState(false);
const [editValue, setEditValue] = useState(node.attrs.config);
const [editValue, setEditValue] = useState(configAttr);
let parsedConfig: ReportConfig | null = null;
let parseError: string | null = null;
try {
const parsed = JSON.parse(node.attrs.config);
const parsed = JSON.parse(configAttr);
if (parsed?.panels && Array.isArray(parsed.panels)) {
parsedConfig = parsed as ReportConfig;
} else {
@ -76,7 +72,7 @@ function ReportBlockView({
if (showSource) {
handleSaveSource();
} else {
setEditValue(node.attrs.config);
setEditValue(configAttr);
setShowSource(true);
}
}}
@ -128,7 +124,7 @@ function ReportBlockView({
<button
type="button"
onClick={() => {
setEditValue(node.attrs.config);
setEditValue(configAttr);
setShowSource(true);
}}
className="report-block-btn"

View File

@ -460,7 +460,7 @@ function SlashPopupRenderer({
}: {
items: SlashItem[];
command: (item: SlashItem) => void;
clientRect: (() => DOMRect | null) | null;
clientRect: (() => DOMRect | null) | null | undefined;
componentRef: React.RefObject<CommandListRef | null>;
}) {
const popupRef = useRef<HTMLDivElement>(null);
@ -497,7 +497,7 @@ function createSuggestionRenderer() {
onStart: (props: {
items: SlashItem[];
command: (item: SlashItem) => void;
clientRect: (() => DOMRect | null) | null;
clientRect?: (() => DOMRect | null) | null;
}) => {
container = document.createElement("div");
document.body.appendChild(container);
@ -517,7 +517,7 @@ function createSuggestionRenderer() {
onUpdate: (props: {
items: SlashItem[];
command: (item: SlashItem) => void;
clientRect: (() => DOMRect | null) | null;
clientRect?: (() => DOMRect | null) | null;
}) => {
root?.render(
<SlashPopupRenderer

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
import { Suspense, useEffect, useState, useCallback, useRef, useMemo } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { WorkspaceSidebar } from "../components/workspace/workspace-sidebar";
import { type TreeNode } from "../components/workspace/file-manager-tree";
@ -171,6 +171,18 @@ function resolveNode(
// --- Main Page ---
export default function WorkspacePage() {
return (
<Suspense fallback={
<div className="flex h-screen items-center justify-center" style={{ background: "var(--color-bg)" }}>
<div className="w-6 h-6 border-2 rounded-full animate-spin" style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }} />
</div>
}>
<WorkspacePageInner />
</Suspense>
);
}
function WorkspacePageInner() {
const searchParams = useSearchParams();
const router = useRouter();
const initialPathHandled = useRef(false);

View File

@ -3,26 +3,9 @@
* Extracted from chat-message.tsx for testability.
*/
export type ReportConfig = {
version: number;
title: string;
description?: string;
panels: Array<{
id: string;
title: string;
type: string;
sql: string;
mapping: Record<string, unknown>;
size?: string;
}>;
filters?: Array<{
id: string;
type: string;
label: string;
column: string;
sql?: string;
}>;
};
import type { ReportConfig } from "../app/components/charts/types";
export type { ReportConfig };
export type ParsedSegment =
| { type: "text"; text: string }

View File

@ -1,6 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Produce a self-contained build in .next/standalone so the npm package
// can run `node .next/standalone/server.js` without a full node_modules.
output: "standalone",
// Allow long-running API routes for agent streaming
serverExternalPackages: [],

View File

@ -1,11 +1,11 @@
{
"name": "openclaw-web",
"name": "ironclaw-web",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openclaw-web",
"name": "ironclaw-web",
"version": "0.1.0",
"dependencies": {
"@ai-sdk/react": "^3.0.75",
@ -33,6 +33,7 @@
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-is": "^19.2.4",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1"
@ -110,6 +111,7 @@
},
"../../node_modules/.pnpm/@types+react-dom@19.1.5_@types+react@19.1.4/node_modules/@types/react-dom": {
"version": "19.1.5",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.0.0"
@ -117,6 +119,7 @@
},
"../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react": {
"version": "19.1.4",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@ -4243,8 +4246,7 @@
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
@ -4958,126 +4960,6 @@
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.3.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.3.tgz",
"integrity": "sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.3.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.3.tgz",
"integrity": "sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.3.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.3.tgz",
"integrity": "sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.3.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.3.tgz",
"integrity": "sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.3.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.3.tgz",
"integrity": "sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.3.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.3.tgz",
"integrity": "sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.3.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.3.tgz",
"integrity": "sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.3.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.3.tgz",
"integrity": "sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}

View File

@ -35,6 +35,7 @@
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-is": "^19.2.4",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1"

View File

@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"target": "ES2023",
"lib": ["dom", "dom.iterable", "ES2023"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,

View File

@ -1,6 +1,6 @@
{
"name": "ironclaw",
"version": "2026.2.6-3",
"version": "2026.2.6-3.4",
"description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management",
"keywords": [],
"license": "MIT",
@ -9,6 +9,11 @@
"ironclaw": "openclaw.mjs"
},
"files": [
"apps/web/.next/standalone/",
"apps/web/.next/static/",
"apps/web/public/",
"apps/web/package.json",
"apps/web/next.config.ts",
"assets/",
"CHANGELOG.md",
"dist/",

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# deploy.sh — build and publish openclaw-ai-sdk to npm
# deploy.sh — build and publish ironclaw to npm
#
# Versioning convention (mirrors upstream openclaw tags):
# --upstream <ver> Sync to an upstream release version.
@ -16,7 +16,7 @@
set -euo pipefail
PACKAGE_NAME="openclaw-ai-sdk"
PACKAGE_NAME="ironclaw"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@ -176,12 +176,17 @@ npm version "$VERSION" --no-git-tag-version --allow-same-version "${NPM_FLAGS[@]
if [[ "$SKIP_BUILD" != true ]]; then
echo "building..."
pnpm build
# Build the Ironclaw Next.js web app so the npm package ships a ready-to-run
# production build (gateway can skip the build step at startup).
echo "building web app..."
(cd apps/web && npm install --legacy-peer-deps && npx next build)
fi
# ── publish ──────────────────────────────────────────────────────────────────
# Always tag as "latest" — npm skips the latest tag for prerelease versions
# by default, but we want `npm i -g openclaw-ai-sdk` to always resolve to
# by default, but we want `npm i -g ironclaw` to always resolve to
# the most recently published version.
echo "publishing ${PACKAGE_NAME}@${VERSION}..."
npm publish --access public --tag latest "${NPM_FLAGS[@]}"

View File

@ -99,6 +99,7 @@ export function applyNonInteractiveGatewayConfig(params: {
mode: tailscaleMode,
resetOnExit: tailscaleResetOnExit,
},
webApp: { enabled: true },
},
};

View File

@ -211,6 +211,15 @@ export type GatewayNodesConfig = {
denyCommands?: string[];
};
export type GatewayWebAppConfig = {
/** If true, the Gateway will build and serve the Ironclaw Next.js web app. Default: false. */
enabled?: boolean;
/** Port for the Next.js web app (default: 3100). */
port?: number;
/** Run in dev mode (`next dev`) instead of production (`next build && next start`). Default: false. */
dev?: boolean;
};
export type GatewayConfig = {
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
port?: number;
@ -245,4 +254,6 @@ export type GatewayConfig = {
* `x-real-ip`) to determine the client IP for local pairing and HTTP checks.
*/
trustedProxies?: string[];
/** Ironclaw Next.js web app served alongside the gateway. */
webApp?: GatewayWebAppConfig;
};

View File

@ -508,6 +508,14 @@ export const OpenClawSchema = z
})
.strict()
.optional(),
webApp: z
.object({
enabled: z.boolean().optional(),
port: z.number().int().positive().optional(),
dev: z.boolean().optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),

View File

@ -3,6 +3,7 @@ import type { WebSocketServer } from "ws";
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
import type { PluginServicesHandle } from "../plugins/services.js";
import type { WebAppHandle } from "./server-web-app.js";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
@ -26,6 +27,7 @@ export function createGatewayCloseHandler(params: {
clients: Set<{ socket: { close: (code: number, reason: string) => void } }>;
configReloader: { stop: () => Promise<void> };
browserControl: { stop: () => Promise<void> } | null;
webApp: WebAppHandle | null;
wss: WebSocketServer;
httpServer: HttpServer;
httpServers?: HttpServer[];
@ -108,6 +110,9 @@ export function createGatewayCloseHandler(params: {
if (params.browserControl) {
await params.browserControl.stop().catch(() => {});
}
if (params.webApp) {
await params.webApp.stop().catch(() => {});
}
await new Promise<void>((resolve) => params.wss.close(() => resolve()));
const servers =
params.httpServers && params.httpServers.length > 0

View File

@ -43,8 +43,13 @@ export async function resolveGatewayRuntimeConfig(params: {
const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback";
const customBindHost = params.cfg.gateway?.customBindHost;
const bindHost = params.host ?? (await resolveGatewayBindHost(bindMode, customBindHost));
// webApp.enabled is the primary toggle for all web UIs.
// controlUi.enabled is a more specific override if set explicitly.
const controlUiEnabled =
params.controlUiEnabled ?? params.cfg.gateway?.controlUi?.enabled ?? true;
params.controlUiEnabled ??
params.cfg.gateway?.controlUi?.enabled ??
params.cfg.gateway?.webApp?.enabled ??
true;
const openAiChatCompletionsEnabled =
params.openAiChatCompletionsEnabled ??
params.cfg.gateway?.http?.endpoints?.chatCompletions?.enabled ??

View File

@ -22,6 +22,7 @@ import {
scheduleRestartSentinelWake,
shouldWakeFromRestartSentinel,
} from "./server-restart-sentinel.js";
import { type WebAppHandle, startWebAppIfEnabled } from "./server-web-app.js";
export async function startGatewaySidecars(params: {
cfg: ReturnType<typeof loadConfig>;
@ -37,6 +38,11 @@ export async function startGatewaySidecars(params: {
};
logChannels: { info: (msg: string) => void; error: (msg: string) => void };
logBrowser: { error: (msg: string) => void };
logWebApp: {
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
};
}) {
// Start OpenClaw browser control server (unless disabled via config).
let browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> = null;
@ -46,6 +52,14 @@ export async function startGatewaySidecars(params: {
params.logBrowser.error(`server failed to start: ${String(err)}`);
}
// Start the Ironclaw Next.js web app if enabled (gateway.webApp.enabled).
let webApp: WebAppHandle | null = null;
try {
webApp = await startWebAppIfEnabled(params.cfg.gateway?.webApp, params.logWebApp);
} catch (err) {
params.logWebApp.error(`web app failed to start: ${String(err)}`);
}
// Start Gmail watcher if configured (hooks.gmail.account).
if (!isTruthyEnvValue(process.env.OPENCLAW_SKIP_GMAIL_WATCHER)) {
try {
@ -156,5 +170,5 @@ export async function startGatewaySidecars(params: {
}, 750);
}
return { browserControl, pluginServices };
return { browserControl, pluginServices, webApp };
}

View File

@ -0,0 +1,184 @@
import type { ChildProcess } from "node:child_process";
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { GatewayWebAppConfig } from "../config/types.gateway.js";
import { isTruthyEnvValue } from "../infra/env.js";
export const DEFAULT_WEB_APP_PORT = 3100;
export type WebAppHandle = {
port: number;
stop: () => Promise<void>;
};
/**
* Resolve the `apps/web` directory relative to the package root.
* Walks up from the current module until we find the workspace root
* (identified by the presence of `apps/web/package.json`).
*/
function resolveWebAppDir(): string | null {
const __filename = fileURLToPath(import.meta.url);
let dir = path.dirname(__filename);
for (let i = 0; i < 10; i++) {
const candidate = path.join(dir, "apps", "web", "package.json");
if (fs.existsSync(candidate)) {
return path.join(dir, "apps", "web");
}
const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return null;
}
/** Check whether a Next.js standalone build exists (from `output: "standalone"`). */
function hasStandaloneBuild(webAppDir: string): boolean {
return fs.existsSync(path.join(webAppDir, ".next", "standalone", "server.js"));
}
/** Check whether a regular Next.js production build exists. */
function hasNextBuild(webAppDir: string): boolean {
return fs.existsSync(path.join(webAppDir, ".next", "BUILD_ID"));
}
/**
* Start the Ironclaw Next.js web app as a child process.
*
* Resolution order:
* 1. Standalone build (`.next/standalone/server.js`) shipped in the npm
* package; runs with plain `node`, no extra deps needed.
* 2. Regular build (`.next/BUILD_ID`) runs via `npx next start`.
* 3. No build builds from source, then starts.
*/
export async function startWebAppIfEnabled(
cfg: GatewayWebAppConfig | undefined,
log: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void },
): Promise<WebAppHandle | null> {
if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_WEB_APP)) {
return null;
}
if (!cfg?.enabled) {
return null;
}
const port = cfg.port ?? DEFAULT_WEB_APP_PORT;
const devMode = cfg.dev === true; // default false (production)
const webAppDir = resolveWebAppDir();
if (!webAppDir) {
log.warn("apps/web directory not found — skipping web app");
return null;
}
let child: ChildProcess;
if (devMode) {
// Dev mode: ensure deps, then `next dev`.
await ensureDepsInstalled(webAppDir, log);
log.info(`starting web app (dev) on port ${port}`);
child = spawn("npx", ["next", "dev", "--port", String(port)], {
cwd: webAppDir,
stdio: "pipe",
env: { ...process.env, PORT: String(port) },
});
} else if (hasStandaloneBuild(webAppDir)) {
// Standalone build: run directly with node — no deps needed.
log.info("using pre-built standalone web app");
const serverJs = path.join(webAppDir, ".next", "standalone", "server.js");
child = spawn(process.execPath, [serverJs], {
cwd: path.join(webAppDir, ".next", "standalone"),
stdio: "pipe",
env: { ...process.env, PORT: String(port), HOSTNAME: "0.0.0.0" },
});
} else {
// No standalone — fall back to regular next start.
await ensureDepsInstalled(webAppDir, log);
if (!hasNextBuild(webAppDir)) {
log.info("building web app for production…");
await runCommand("npx", ["next", "build"], webAppDir);
} else {
log.info("pre-built web app found — skipping build");
}
log.info(`starting web app (production) on port ${port}`);
child = spawn("npx", ["next", "start", "--port", String(port)], {
cwd: webAppDir,
stdio: "pipe",
env: { ...process.env, PORT: String(port) },
});
}
// Forward child stdout/stderr to the gateway log.
child.stdout?.on("data", (data: Buffer) => {
for (const line of data.toString().split("\n").filter(Boolean)) {
log.info(line);
}
});
child.stderr?.on("data", (data: Buffer) => {
for (const line of data.toString().split("\n").filter(Boolean)) {
log.warn(line);
}
});
child.on("error", (err) => {
log.error(`web app process error: ${String(err)}`);
});
child.on("exit", (code, signal) => {
if (code !== null && code !== 0) {
log.warn(`web app exited with code ${code}`);
} else if (signal) {
log.info(`web app terminated by signal ${signal}`);
}
});
log.info(`web app available at http://localhost:${port}`);
return {
port,
stop: async () => {
if (child.exitCode === null && !child.killed) {
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (child.exitCode === null && !child.killed) {
child.kill("SIGKILL");
}
resolve();
}, 5_000);
child.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
},
};
}
// ── helpers ──────────────────────────────────────────────────────────────────
async function ensureDepsInstalled(
webAppDir: string,
log: { info: (msg: string) => void },
): Promise<void> {
const nodeModulesDir = path.join(webAppDir, "node_modules");
if (fs.existsSync(nodeModulesDir)) {
return;
}
log.info("installing web app dependencies…");
await runCommand("pnpm", ["install"], webAppDir);
}
function runCommand(cmd: string, args: string[], cwd: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const proc = spawn(cmd, args, { cwd, stdio: "pipe", env: { ...process.env } });
proc.on("close", (code) =>
code === 0 ? resolve() : reject(new Error(`${cmd} ${args[0]} exited with code ${code}`)),
);
proc.on("error", reject);
});
}

View File

@ -88,6 +88,7 @@ const logDiscovery = log.child("discovery");
const logTailscale = log.child("tailscale");
const logChannels = log.child("channels");
const logBrowser = log.child("browser");
const logWebApp = log.child("webapp");
const logHealth = log.child("health");
const logCron = log.child("cron");
const logReload = log.child("reload");
@ -217,6 +218,27 @@ export async function startGatewayServer(
}
}
// Ensure gateway.webApp is enabled by default for all configs.
// Existing configs created before the web app feature won't have this key,
// so we backfill it on startup and persist.
{
const currentCfg = loadConfig();
if (currentCfg.gateway?.webApp === undefined) {
try {
await writeConfigFile({
...currentCfg,
gateway: {
...currentCfg.gateway,
webApp: { enabled: true },
},
});
log.info("gateway: auto-enabled webApp (gateway.webApp.enabled = true)");
} catch (err) {
log.warn(`gateway: failed to persist webApp auto-enable: ${String(err)}`);
}
}
}
const cfgAtStart = loadConfig();
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
if (diagnosticsEnabled) {
@ -546,7 +568,8 @@ export async function startGatewayServer(
});
let browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> = null;
({ browserControl, pluginServices } = await startGatewaySidecars({
let webApp: Awaited<ReturnType<typeof startGatewaySidecars>>["webApp"] = null;
({ browserControl, pluginServices, webApp } = await startGatewaySidecars({
cfg: cfgAtStart,
pluginRegistry,
defaultWorkspaceDir,
@ -556,6 +579,7 @@ export async function startGatewayServer(
logHooks,
logChannels,
logBrowser,
logWebApp,
}));
const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({
@ -617,6 +641,7 @@ export async function startGatewayServer(
clients,
configReloader,
browserControl,
webApp,
wss,
httpServer,
httpServers,

View File

@ -33,6 +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 { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { restoreTerminalState } from "../terminal/restore.js";
import { runTui } from "../tui/tui.js";
@ -345,34 +346,23 @@ export async function finalizeOnboardingWizard(
});
launchedTui = true;
} else if (hatchChoice === "web") {
const webAppPort = nextConfig.gateway?.webApp?.port ?? DEFAULT_WEB_APP_PORT;
const webAppUrl = `http://localhost:${webAppPort}`;
const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) {
controlUiOpened = await openUrl(authedUrl);
if (!controlUiOpened) {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
basePath: controlUiBasePath,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
});
}
} else {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
basePath: controlUiBasePath,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
});
controlUiOpened = await openUrl(webAppUrl);
}
await prompter.note(
[
`Dashboard link (with token): ${authedUrl}`,
`Ironclaw Web UI: ${webAppUrl}`,
controlUiOpened
? "Opened in your browser. Keep that tab to control Ironclaw."
: "Copy/paste this URL in a browser on this machine to control Ironclaw.",
controlUiOpenHint,
? "Opened in your browser."
: "Copy/paste this URL in a browser on this machine.",
`Dashboard (control UI): ${authedUrl}`,
]
.filter(Boolean)
.join("\n"),
"Dashboard ready",
"Web UI ready",
);
} else {
await prompter.note(
@ -515,10 +505,10 @@ export async function finalizeOnboardingWizard(
await prompter.outro(
controlUiOpened
? "Onboarding complete. Dashboard opened; keep that tab to control Ironclaw."
? "Onboarding complete. Web UI opened; keep that tab to control Ironclaw."
: seededInBackground
? "Onboarding complete. Web UI seeded in the background; open it anytime with the dashboard link above."
: "Onboarding complete. Use the dashboard link above to control Ironclaw.",
? "Onboarding complete. Web UI seeded in the background; open it anytime with the link above."
: "Onboarding complete. Use the links above to control Ironclaw.",
);
return { launchedTui };

View File

@ -247,6 +247,7 @@ export async function configureGatewayForOnboarding(
mode: tailscaleMode as GatewayTailscaleMode,
resetOnExit: tailscaleResetOnExit,
},
webApp: { enabled: true },
},
};