From dee6c85225abea4d0181d60b98c50f655532b139 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Sun, 15 Feb 2026 15:35:31 -0800 Subject: [PATCH] Gateway: auto-open Next.js web app on start/restart/onboard, remove TUI/Web hatch prompt --- src/gateway/server-startup.ts | 17 +++ src/gateway/server-web-app.test.ts | 3 +- src/gateway/server-web-app.ts | 52 +++++++- src/wizard/onboarding.finalize.ts | 57 ++------- src/wizard/onboarding.test.ts | 184 ++--------------------------- 5 files changed, 88 insertions(+), 225 deletions(-) diff --git a/src/gateway/server-startup.ts b/src/gateway/server-startup.ts index a290c27937b..e5f38d6c4f4 100644 --- a/src/gateway/server-startup.ts +++ b/src/gateway/server-startup.ts @@ -61,6 +61,23 @@ export async function startGatewaySidecars(params: { params.logWebApp.error(`web app failed to start: ${String(err)}`); } + // Auto-open the web app in the default browser on every gateway start/restart. + if (webApp) { + try { + const { detectBrowserOpenSupport, openUrl } = await import("../commands/onboard-helpers.js"); + const browserSupport = await detectBrowserOpenSupport(); + if (browserSupport.ok) { + const webAppUrl = `http://localhost:${webApp.port}`; + const opened = await openUrl(webAppUrl); + if (opened) { + params.logWebApp.info(`opened ${webAppUrl} in browser`); + } + } + } catch { + // Browser open is best-effort; don't fail gateway startup. + } + } + // Start Gmail watcher if configured (hooks.gmail.account). if (!isTruthyEnvValue(process.env.OPENCLAW_SKIP_GMAIL_WATCHER)) { try { diff --git a/src/gateway/server-web-app.test.ts b/src/gateway/server-web-app.test.ts index f157eb9d187..00d15a79957 100644 --- a/src/gateway/server-web-app.test.ts +++ b/src/gateway/server-web-app.test.ts @@ -370,7 +370,8 @@ describe("server-web-app", () => { const resultPromise = startWebAppIfEnabled({ enabled: true }, makeLog()); await vi.advanceTimersByTimeAsync(3_500); const result = await resultPromise; - expect(result!.port).toBe(DEFAULT_WEB_APP_PORT); + // Port detection picks the default port if free, or the next available. + expect(result!.port).toBeGreaterThanOrEqual(DEFAULT_WEB_APP_PORT); vi.useRealTimers(); }); diff --git a/src/gateway/server-web-app.ts b/src/gateway/server-web-app.ts index 7751030cb82..4ed99c6206a 100644 --- a/src/gateway/server-web-app.ts +++ b/src/gateway/server-web-app.ts @@ -1,6 +1,7 @@ import type { ChildProcess } from "node:child_process"; import { spawn } from "node:child_process"; import fs from "node:fs"; +import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { GatewayWebAppConfig } from "../config/types.gateway.js"; @@ -81,6 +82,51 @@ export function isInWorkspace(webAppDir: string): boolean { return fs.existsSync(path.join(rootDir, "pnpm-workspace.yaml")); } +// ── port detection ─────────────────────────────────────────────────────────── + +/** + * Check whether a TCP port is free by attempting to bind a temporary server. + */ +function isPortFree(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port); + }); +} + +/** + * Find an available port, preferring `preferred`. + * + * 1. If `preferred` is free, return it immediately. + * 2. Try up to 10 sequential ports (preferred+1 … preferred+10). + * 3. Fall back to an OS-assigned ephemeral port. + */ +export async function findAvailablePort(preferred: number): Promise { + if (await isPortFree(preferred)) { + return preferred; + } + for (let offset = 1; offset <= 10; offset++) { + const candidate = preferred + offset; + if (candidate <= 65535 && (await isPortFree(candidate))) { + return candidate; + } + } + // OS-assigned ephemeral port as last resort. + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + server.close(() => resolve(port)); + }); + }); +} + // ── pre-build ──────────────────────────────────────────────────────────────── export type EnsureWebAppBuiltResult = { @@ -200,7 +246,11 @@ export async function startWebAppIfEnabled( return null; } - const port = cfg.port ?? DEFAULT_WEB_APP_PORT; + const preferredPort = cfg.port ?? DEFAULT_WEB_APP_PORT; + const port = await findAvailablePort(preferredPort); + if (port !== preferredPort) { + log.info(`port ${preferredPort} is busy; using port ${port} instead`); + } const devMode = cfg.dev === true; const webAppDir = resolveWebAppDir(); diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index e2930aab888..80f5f334925 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -29,8 +29,6 @@ import { resolveGatewayService } from "../daemon/service.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.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"; import { resolveUserPath } from "../utils.js"; import { setupOnboardingShellCompletion } from "./onboarding.completion.js"; @@ -301,22 +299,8 @@ export async function finalizeOnboardingWizard( let controlUiOpened = false; let controlUiOpenHint: string | undefined; let seededInBackground = false; - let hatchChoice: "tui" | "web" | "later" | null = null; - let launchedTui = false; if (!opts.skipUi && gatewayProbe.ok) { - if (hasBootstrap) { - await prompter.note( - [ - "This is the defining action that makes your agent you.", - "Please take your time.", - "The more you tell it, the better the experience will be.", - 'We will send: "Wake up, my friend!"', - ].join("\n"), - "Start TUI (best option!)", - ); - } - await prompter.note( [ "Gateway token: shared auth for the Gateway + Control UI.", @@ -330,33 +314,8 @@ 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. + // Always open the Next.js web app in the browser (TUI available via `openclaw tui`). 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: hatchOptions, - initialValue: "tui", - }); - - if (hatchChoice === "tui") { - restoreTerminalState("pre-onboarding tui", { resumeStdinIfPaused: true }); - await runTui({ - url: links.wsUrl, - token: settings.authMode === "token" ? settings.gatewayToken : undefined, - password: settings.authMode === "password" ? nextConfig.gateway?.auth?.password : "", - // Safety: onboarding TUI should not auto-deliver to lastProvider/lastTo. - deliver: false, - message: hasBootstrap ? "Wake up, my friend!" : undefined, - }); - 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(); @@ -370,6 +329,7 @@ export async function finalizeOnboardingWizard( ? "Opened in your browser." : "Copy/paste this URL in a browser on this machine.", `Dashboard (control UI): ${authedUrl}`, + `Start TUI manually: ${formatCliCommand("openclaw tui")}`, ] .filter(Boolean) .join("\n"), @@ -377,8 +337,12 @@ export async function finalizeOnboardingWizard( ); } else { await prompter.note( - `When you're ready: ${formatCliCommand("openclaw dashboard --no-open")}`, - "Later", + [ + "Web app build not available — skipping browser open.", + `Start TUI manually: ${formatCliCommand("openclaw tui")}`, + `Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`, + ].join("\n"), + "Web UI", ); } } else if (opts.skipUi) { @@ -400,11 +364,12 @@ export async function finalizeOnboardingWizard( await setupOnboardingShellCompletion({ flow, prompter }); + // Open the Control UI dashboard if we didn't already open the web app above. const shouldOpenControlUi = !opts.skipUi && settings.authMode === "token" && Boolean(settings.gatewayToken) && - hatchChoice === null; + !controlUiOpened; if (shouldOpenControlUi) { const browserSupport = await detectBrowserOpenSupport(); if (browserSupport.ok) { @@ -479,5 +444,5 @@ export async function finalizeOnboardingWizard( : "Onboarding complete. Use the links above to control Ironclaw.", ); - return { launchedTui }; + return { launchedTui: false }; } diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts index b931608e2df..8d2ee5e25f1 100644 --- a/src/wizard/onboarding.test.ts +++ b/src/wizard/onboarding.test.ts @@ -34,28 +34,8 @@ const finalizeOnboardingWizard = vi.hoisted(() => await options.prompter.note("hint", "Web search (optional)"); } - if (options.opts.skipUi) { - return { launchedTui: false }; - } - - const hatch = await options.prompter.select({ - message: "How do you want to hatch your bot?", - options: [], - }); - if (hatch !== "tui") { - return { launchedTui: false }; - } - - let message: string | undefined; - try { - await fs.stat(path.join(options.workspaceDir, DEFAULT_BOOTSTRAP_FILENAME)); - message = "Wake up, my friend!"; - } catch { - message = undefined; - } - - await runTui({ deliver: false, message }); - return { launchedTui: true }; + // No hatch prompt — the web app auto-opens in the browser. + return { launchedTui: false }; }), ); const listChannelPlugins = vi.hoisted(() => vi.fn(() => [])); @@ -306,25 +286,11 @@ describe("runOnboardingWizard", () => { expect(runTui).not.toHaveBeenCalled(); }); - async function runTuiHatchTest(params: { - writeBootstrapFile: boolean; - expectedMessage: string | undefined; - }) { - runTui.mockClear(); - + it("does not launch TUI during onboarding (web app auto-opens instead)", async () => { const workspaceDir = await makeCaseDir("workspace-"); - if (params.writeBootstrapFile) { - await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}"); - } + await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}"); - const select: WizardPrompter["select"] = vi.fn(async (opts) => { - if (opts.message === "How do you want to hatch your bot?") { - return "tui"; - } - return "quickstart"; - }); - - const prompter = createWizardPrompter({ select }); + const prompter = createWizardPrompter(); const runtime = createRuntime({ throwsOnExit: true }); await runOnboardingWizard( @@ -343,144 +309,8 @@ describe("runOnboardingWizard", () => { prompter, ); - expect(runTui).toHaveBeenCalledWith( - expect.objectContaining({ - deliver: false, - message: params.expectedMessage, - }), - ); - } - - it("launches TUI without auto-delivery when hatching", async () => { - await runTuiHatchTest({ writeBootstrapFile: true, expectedMessage: "Wake up, my friend!" }); - }); - - it("offers TUI hatch even without BOOTSTRAP.md", async () => { - await runTuiHatchTest({ writeBootstrapFile: false, expectedMessage: undefined }); - }); - - 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 }); + // TUI should never be launched — web app is the default. + expect(runTui).not.toHaveBeenCalled(); }); it("shows the web search hint at the end of onboarding", async () => {