Web app: fix pnpm standalone packaging and add startup crash detection

- 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 <cursoragent@cursor.com>
This commit is contained in:
kumarabhirup 2026-02-12 20:02:17 -08:00
parent dbddde9477
commit 15b0b0bcc8
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
7 changed files with 316 additions and 13 deletions

View File

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

View File

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

View File

@ -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/<package>@<version>/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"

View File

@ -189,8 +189,9 @@ describe("server-web-app", () => {
function mockChildProcess() {
const events: Record<string, ((...args: unknown[]) => void)[]> = {};
const onceEvents: Record<string, ((...args: unknown[]) => 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();
});
});
});

View File

@ -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 ──────────────────────────────────────────────────────────────────
/**

View File

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

View File

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