Gateway: auto-open Next.js web app on start/restart/onboard, remove TUI/Web hatch prompt
This commit is contained in:
parent
e4c94cc012
commit
dee6c85225
@ -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 {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user