From 15b0b0bcc8c7db2f5c70580aa06e129b98a2cb38 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Thu, 12 Feb 2026 20:02:17 -0800 Subject: [PATCH] Web app: fix pnpm standalone packaging and add startup crash detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add standalone-hoist-pnpm.sh to hoist .pnpm packages to top-level node_modules so require('next') resolves in global npm installs (the pnpm symlinks don't survive npm tarball packing) - Add startup probe (waitForStartupOrCrash) to detect child process crashes within 3s instead of silently returning a handle to a dead server — logs clear error with stderr output - Gate "Open the Web UI" onboarding hatch option on web app build availability so users aren't offered a dead URL - Add post-publish sanity check in deploy.sh for standalone server.js Co-authored-by: Cursor --- package.json | 4 +- scripts/deploy.sh | 13 +++ scripts/standalone-hoist-pnpm.sh | 49 ++++++++++++ src/gateway/server-web-app.test.ts | 76 ++++++++++++++++-- src/gateway/server-web-app.ts | 46 ++++++++++- src/wizard/onboarding.finalize.ts | 17 ++-- src/wizard/onboarding.test.ts | 124 +++++++++++++++++++++++++++++ 7 files changed, 316 insertions(+), 13 deletions(-) create mode 100755 scripts/standalone-hoist-pnpm.sh diff --git a/package.json b/package.json index cf0b8ea9a02..f820cfafb94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ironclaw", - "version": "2026.2.10-1.5", + "version": "2026.2.10-1.8", "description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management", "keywords": [], "license": "MIT", @@ -113,7 +113,7 @@ "web:build": "pnpm --dir apps/web build", "web:dev": "pnpm --dir apps/web dev", "web:install": "pnpm --dir apps/web install", - "web:prepack": "cp -r apps/web/public apps/web/.next/standalone/apps/web/public && cp -r apps/web/.next/static apps/web/.next/standalone/apps/web/.next/static" + "web:prepack": "cp -r apps/web/public apps/web/.next/standalone/apps/web/public && cp -r apps/web/.next/static apps/web/.next/standalone/apps/web/.next/static && bash scripts/standalone-hoist-pnpm.sh" }, "dependencies": { "@agentclientprotocol/sdk": "0.14.1", diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c150e3ffbe3..9478d255bd4 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -173,6 +173,10 @@ npm version "$VERSION" --no-git-tag-version --allow-same-version "${NPM_FLAGS[@] # ── build ──────────────────────────────────────────────────────────────────── +# The `prepack` script (triggered by `npm publish`) runs the full build chain: +# pnpm build && pnpm ui:build && pnpm web:build && pnpm web:prepack +# Running `pnpm build` here is a redundant fail-fast: catch CLI build errors +# before committing to a publish attempt. if [[ "$SKIP_BUILD" != true ]]; then echo "building..." pnpm build @@ -186,6 +190,15 @@ fi echo "publishing ${PACKAGE_NAME}@${VERSION}..." npm publish --access public --tag latest "${NPM_FLAGS[@]}" +# Verify the standalone web app was included in the published package. +# `prepack` should have built it; if this file is missing, the web UI +# won't work for users who install globally. +STANDALONE_SERVER="apps/web/.next/standalone/apps/web/server.js" +if [[ ! -f "$STANDALONE_SERVER" ]]; then + echo "warning: standalone web app build not found after publish ($STANDALONE_SERVER)" + echo " users may not get a working Web UI — check the prepack step" +fi + echo "" echo "published ${PACKAGE_NAME}@${VERSION}" echo "install: npm i -g ${PACKAGE_NAME}" diff --git a/scripts/standalone-hoist-pnpm.sh b/scripts/standalone-hoist-pnpm.sh new file mode 100755 index 00000000000..7969d42a2de --- /dev/null +++ b/scripts/standalone-hoist-pnpm.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# standalone-hoist-pnpm.sh — Hoist pnpm .pnpm packages to top-level node_modules +# +# The Next.js standalone build with pnpm stores traced dependencies inside +# .pnpm/@/node_modules/. Node's require() can't resolve +# bare imports like require('next') from that structure because the top-level +# symlinks that pnpm normally creates don't survive npm tarball packing. +# +# This script copies each package from .pnpm/*/node_modules/ to the +# standalone root node_modules/ so require() works in global npm installs, +# then removes .pnpm to avoid file duplication in the tarball. + +set -euo pipefail + +STANDALONE_NM="apps/web/.next/standalone/node_modules" + +if [ ! -d "$STANDALONE_NM/.pnpm" ]; then + echo "[standalone-hoist] no .pnpm directory found — skipping" + exit 0 +fi + +echo "[standalone-hoist] hoisting .pnpm packages to top-level node_modules…" + +for inner_nm in "$STANDALONE_NM"/.pnpm/*/node_modules; do + [ -d "$inner_nm" ] || continue + for pkg in "$inner_nm"/*; do + [ -e "$pkg" ] || continue + name="$(basename "$pkg")" + + if [[ "$name" == @* ]]; then + # Scoped package dir (e.g. @next/) — merge children individually + # so multiple .pnpm entries with different @scope children combine. + mkdir -p "$STANDALONE_NM/$name" + for child in "$pkg"/*; do + [ -e "$child" ] || continue + child_name="$(basename "$child")" + [ -e "$STANDALONE_NM/$name/$child_name" ] || cp -r "$child" "$STANDALONE_NM/$name/$child_name" + done + else + # Regular package — copy if not already present. + [ -e "$STANDALONE_NM/$name" ] || cp -r "$pkg" "$STANDALONE_NM/$name" + fi + done +done + +# Remove .pnpm to avoid double-shipping files in the npm tarball. +rm -rf "$STANDALONE_NM/.pnpm" + +echo "[standalone-hoist] done" diff --git a/src/gateway/server-web-app.test.ts b/src/gateway/server-web-app.test.ts index b700c0acade..f157eb9d187 100644 --- a/src/gateway/server-web-app.test.ts +++ b/src/gateway/server-web-app.test.ts @@ -189,8 +189,9 @@ describe("server-web-app", () => { function mockChildProcess() { const events: Record void)[]> = {}; + const onceEvents: Record void)[]> = {}; const child = { - exitCode: null, + exitCode: null as number | null, killed: false, pid: 12345, stdout: { on: vi.fn() }, @@ -199,11 +200,30 @@ describe("server-web-app", () => { events[event] = events[event] || []; events[event].push(cb); }), + once: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + onceEvents[event] = onceEvents[event] || []; + onceEvents[event].push(cb); + }), + removeListener: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + const arr = onceEvents[event]; + if (arr) { + const idx = arr.indexOf(cb); + if (idx >= 0) { + arr.splice(idx, 1); + } + } + }), kill: vi.fn(), _emit: (event: string, ...args: unknown[]) => { for (const cb of events[event] || []) { cb(...args); } + // Fire and remove once listeners. + const once = onceEvents[event] || []; + onceEvents[event] = []; + for (const cb of once) { + cb(...args); + } }, }; vi.mocked(spawn).mockReturnValue(child as unknown as ChildProcess); @@ -241,6 +261,7 @@ describe("server-web-app", () => { }); it("starts standalone server.js in production mode", async () => { + vi.useFakeTimers(); const { startWebAppIfEnabled } = await import("./server-web-app.js"); mockChildProcess(); @@ -256,7 +277,9 @@ describe("server-web-app", () => { }); const log = makeLog(); - const result = await startWebAppIfEnabled({ enabled: true, port: 4000 }, log); + const resultPromise = startWebAppIfEnabled({ enabled: true, port: 4000 }, log); + await vi.advanceTimersByTimeAsync(3_500); + const result = await resultPromise; expect(result).not.toBeNull(); expect(result!.port).toBe(4000); @@ -272,9 +295,11 @@ describe("server-web-app", () => { expect(log.info).toHaveBeenCalledWith(expect.stringContaining("standalone")); expect(log.info).not.toHaveBeenCalledWith(expect.stringContaining("installing")); expect(log.info).not.toHaveBeenCalledWith(expect.stringContaining("building")); + vi.useRealTimers(); }); it("falls back to legacy next start when BUILD_ID exists but no standalone", async () => { + vi.useFakeTimers(); const { startWebAppIfEnabled } = await import("./server-web-app.js"); mockChildProcess(); @@ -295,12 +320,15 @@ describe("server-web-app", () => { }); const log = makeLog(); - const result = await startWebAppIfEnabled({ enabled: true }, log); + const resultPromise = startWebAppIfEnabled({ enabled: true }, log); + await vi.advanceTimersByTimeAsync(3_500); + const result = await resultPromise; expect(result).not.toBeNull(); expect(log.warn).toHaveBeenCalledWith( expect.stringContaining("falling back to legacy next start"), ); + vi.useRealTimers(); }); it("returns null with error for global install when no build exists", async () => { @@ -324,6 +352,7 @@ describe("server-web-app", () => { }); it("uses default port when not specified", async () => { + vi.useFakeTimers(); const { startWebAppIfEnabled, DEFAULT_WEB_APP_PORT } = await import("./server-web-app.js"); mockChildProcess(); @@ -338,11 +367,15 @@ describe("server-web-app", () => { return false; }); - const result = await startWebAppIfEnabled({ enabled: true }, makeLog()); + const resultPromise = startWebAppIfEnabled({ enabled: true }, makeLog()); + await vi.advanceTimersByTimeAsync(3_500); + const result = await resultPromise; expect(result!.port).toBe(DEFAULT_WEB_APP_PORT); + vi.useRealTimers(); }); it("stop() sends SIGTERM then resolves on exit", async () => { + vi.useFakeTimers(); const { startWebAppIfEnabled } = await import("./server-web-app.js"); const child = mockChildProcess(); @@ -357,7 +390,9 @@ describe("server-web-app", () => { return false; }); - const result = await startWebAppIfEnabled({ enabled: true }, makeLog()); + const resultPromise = startWebAppIfEnabled({ enabled: true }, makeLog()); + await vi.advanceTimersByTimeAsync(3_500); + const result = await resultPromise; expect(result).not.toBeNull(); // Simulate: process hasn't exited yet. @@ -367,6 +402,37 @@ describe("server-web-app", () => { // Simulate the exit event. child._emit("exit", 0, null); await stopPromise; + vi.useRealTimers(); + }); + + it("returns null and logs error when child process crashes on startup", async () => { + vi.useFakeTimers(); + const { startWebAppIfEnabled } = await import("./server-web-app.js"); + const child = mockChildProcess(); + + existsSyncSpy.mockImplementation((p) => { + const s = String(p); + if (s.endsWith(path.join("apps", "web", "package.json"))) { + return true; + } + if (s.includes(path.join(".next", "standalone", "apps", "web", "server.js"))) { + return true; + } + return false; + }); + + const log = makeLog(); + const resultPromise = startWebAppIfEnabled({ enabled: true }, log); + + // Simulate the child crashing immediately (e.g. Cannot find module 'next'). + child.exitCode = 1; + child._emit("exit", 1, null); + + const result = await resultPromise; + + expect(result).toBeNull(); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining("web app failed to start")); + vi.useRealTimers(); }); }); }); diff --git a/src/gateway/server-web-app.ts b/src/gateway/server-web-app.ts index bbf367ab122..4c4d1c38837 100644 --- a/src/gateway/server-web-app.ts +++ b/src/gateway/server-web-app.ts @@ -272,6 +272,9 @@ export async function startWebAppIfEnabled( } } + // Collect stderr lines for crash diagnostics. + const stderrLines: string[] = []; + // Forward child stdout/stderr to the gateway log. child.stdout?.on("data", (data: Buffer) => { for (const line of data.toString().split("\n").filter(Boolean)) { @@ -280,6 +283,7 @@ export async function startWebAppIfEnabled( }); child.stderr?.on("data", (data: Buffer) => { for (const line of data.toString().split("\n").filter(Boolean)) { + stderrLines.push(line); log.warn(line); } }); @@ -290,12 +294,23 @@ export async function startWebAppIfEnabled( child.on("exit", (code, signal) => { if (code !== null && code !== 0) { - log.warn(`web app exited with code ${code}`); + log.error(`web app crashed (exit code ${code}) — http://localhost:${port} will not work`); } else if (signal) { log.info(`web app terminated by signal ${signal}`); } }); + // Wait briefly for the child to either settle or crash on startup. + // Most fatal errors (missing modules, bad config) surface within a + // couple of seconds. Without this, we'd log "web app available" even + // though the process has already exited. + const crashed = await waitForStartupOrCrash(child, 3_000); + if (crashed) { + const detail = stderrLines.length > 0 ? `: ${stderrLines.slice(-3).join(" | ")}` : ""; + log.error(`web app failed to start (exit code ${crashed.code})${detail}`); + return null; + } + log.info(`web app available at http://localhost:${port}`); return { @@ -320,6 +335,35 @@ export async function startWebAppIfEnabled( }; } +/** + * Wait up to `timeoutMs` for the child process to either stay alive + * (server started successfully) or exit (crash on startup). + * + * Returns null if the process is still running after the timeout, + * or `{ code }` if it exited during the wait. + */ +function waitForStartupOrCrash( + child: ChildProcess, + timeoutMs: number, +): Promise<{ code: number | null } | null> { + // Already exited before we even started waiting. + if (child.exitCode !== null) { + return Promise.resolve({ code: child.exitCode }); + } + return new Promise((resolve) => { + const timer = setTimeout(() => { + // Still running after timeout — assume healthy. + child.removeListener("exit", onExit); + resolve(null); + }, timeoutMs); + function onExit(code: number | null) { + clearTimeout(timer); + resolve({ code }); + } + child.once("exit", onExit); + }); +} + // ── helpers ────────────────────────────────────────────────────────────────── /** diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index 5159425727e..ec7b9c22fde 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -90,10 +90,12 @@ export async function finalizeOnboardingWizard( // gateway doesn't block on builds when it boots for the first time. const controlUiEnabled = nextConfig.gateway?.controlUi?.enabled ?? baseConfig.gateway?.controlUi?.enabled ?? true; + let webAppReady = false; if (!opts.skipUi && controlUiEnabled) { const webAppResult = await ensureWebAppBuilt(runtime, { webAppConfig: nextConfig.gateway?.webApp, }); + webAppReady = webAppResult.ok; if (!webAppResult.ok && webAppResult.message) { runtime.error(webAppResult.message); } @@ -328,13 +330,18 @@ export async function finalizeOnboardingWizard( "Token", ); + const hatchOptions: { value: "tui" | "web" | "later"; label: string }[] = [ + { value: "tui", label: "Hatch in TUI (recommended)" }, + ]; + // Only offer Web UI when the build is present so we don't open a dead URL. + if (webAppReady) { + hatchOptions.push({ value: "web", label: "Open the Web UI" }); + } + hatchOptions.push({ value: "later", label: "Do this later" }); + hatchChoice = await prompter.select({ message: "How do you want to hatch your bot?", - options: [ - { value: "tui", label: "Hatch in TUI (recommended)" }, - { value: "web", label: "Open the Web UI" }, - { value: "later", label: "Do this later" }, - ], + options: hatchOptions, initialValue: "tui", }); diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts index 1c097a034aa..bfa4f96e075 100644 --- a/src/wizard/onboarding.test.ts +++ b/src/wizard/onboarding.test.ts @@ -291,6 +291,130 @@ describe("runOnboardingWizard", () => { await fs.rm(workspaceDir, { recursive: true, force: true }); }); + it("hides Web UI hatch option when web app build is not available", async () => { + // Simulate a global install where the standalone build is missing. + ensureWebAppBuilt.mockResolvedValueOnce({ + ok: false, + built: false, + message: "Web app standalone build not found.", + }); + + const selectCalls: { message: string; options: { value: string }[] }[] = []; + const select: WizardPrompter["select"] = vi.fn(async (opts) => { + selectCalls.push(opts as (typeof selectCalls)[0]); + if (opts.message === "How do you want to hatch your bot?") { + return "tui"; + } + return "quickstart"; + }); + + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-")); + + const prompter: WizardPrompter = { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select, + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }; + + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + await runOnboardingWizard( + { + acceptRisk: true, + flow: "quickstart", + mode: "local", + workspace: workspaceDir, + authChoice: "skip", + skipProviders: true, + skipSkills: true, + skipHealth: true, + installDaemon: false, + }, + runtime, + prompter, + ); + + // The hatch prompt should NOT include the "web" option. + const hatchCall = selectCalls.find((c) => c.message === "How do you want to hatch your bot?"); + expect(hatchCall).toBeDefined(); + const hatchValues = hatchCall!.options.map((o) => o.value); + expect(hatchValues).toContain("tui"); + expect(hatchValues).not.toContain("web"); + expect(hatchValues).toContain("later"); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("shows Web UI hatch option when web app build is available", async () => { + // Default mock returns ok: true — web app is available. + const selectCalls: { message: string; options: { value: string }[] }[] = []; + const select: WizardPrompter["select"] = vi.fn(async (opts) => { + selectCalls.push(opts as (typeof selectCalls)[0]); + if (opts.message === "How do you want to hatch your bot?") { + return "tui"; + } + return "quickstart"; + }); + + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-")); + + const prompter: WizardPrompter = { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select, + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }; + + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + await runOnboardingWizard( + { + acceptRisk: true, + flow: "quickstart", + mode: "local", + workspace: workspaceDir, + authChoice: "skip", + skipProviders: true, + skipSkills: true, + skipHealth: true, + installDaemon: false, + }, + runtime, + prompter, + ); + + // The hatch prompt SHOULD include the "web" option. + const hatchCall = selectCalls.find((c) => c.message === "How do you want to hatch your bot?"); + expect(hatchCall).toBeDefined(); + const hatchValues = hatchCall!.options.map((o) => o.value); + expect(hatchValues).toContain("tui"); + expect(hatchValues).toContain("web"); + expect(hatchValues).toContain("later"); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + it("shows the web search hint at the end of onboarding", async () => { const prevBraveKey = process.env.BRAVE_API_KEY; delete process.env.BRAVE_API_KEY;