Gateway: auto-open Next.js web app on start/restart/onboard, remove TUI/Web hatch prompt

This commit is contained in:
kumarabhirup 2026-02-15 15:35:31 -08:00
parent e4c94cc012
commit dee6c85225
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
5 changed files with 88 additions and 225 deletions

View File

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

View File

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

View File

@ -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<boolean> {
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<number> {
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<number>((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();

View File

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

View File

@ -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 () => {