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:
parent
dbddde9477
commit
15b0b0bcc8
@ -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",
|
||||
|
||||
@ -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}"
|
||||
|
||||
49
scripts/standalone-hoist-pnpm.sh
Executable file
49
scripts/standalone-hoist-pnpm.sh
Executable 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"
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user