import fs from "node:fs/promises"; import path from "node:path"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, GATEWAY_DAEMON_RUNTIME_OPTIONS, type GatewayDaemonRuntime, } from "../commands/daemon-runtime.js"; import { healthCommand } from "../commands/health.js"; import { formatHealthCheckFailure } from "../commands/health-format.js"; import { detectBrowserOpenSupport, formatControlUiSshHint, openUrl, probeGatewayReachable, waitForGatewayReachable, resolveControlUiLinks, } from "../commands/onboard-helpers.js"; import type { OnboardOptions } from "../commands/onboard-types.js"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { renderSystemNodeWarning, resolvePreferredNodePath, resolveSystemNodeInfo, } from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import type { RuntimeEnv } from "../runtime.js"; import { runTui } from "../tui/tui.js"; import { resolveUserPath } from "../utils.js"; import type { GatewayWizardSettings, WizardFlow } from "./onboarding.types.js"; import type { WizardPrompter } from "./prompts.js"; type FinalizeOnboardingOptions = { flow: WizardFlow; opts: OnboardOptions; baseConfig: ClawdbotConfig; nextConfig: ClawdbotConfig; workspaceDir: string; settings: GatewayWizardSettings; prompter: WizardPrompter; runtime: RuntimeEnv; }; export async function finalizeOnboardingWizard(options: FinalizeOnboardingOptions) { const { flow, opts, baseConfig, nextConfig, settings, prompter, runtime } = options; const withWizardProgress = async ( label: string, options: { doneMessage?: string }, work: (progress: { update: (message: string) => void }) => Promise, ): Promise => { const progress = prompter.progress(label); try { return await work(progress); } finally { progress.stop(options.doneMessage); } }; const systemdAvailable = process.platform === "linux" ? await isSystemdUserServiceAvailable() : true; if (process.platform === "linux" && !systemdAvailable) { await prompter.note( "Systemd user services are unavailable. Skipping lingering checks and daemon install.", "Systemd", ); } if (process.platform === "linux" && systemdAvailable) { const { ensureSystemdUserLingerInteractive } = await import("../commands/systemd-linger.js"); await ensureSystemdUserLingerInteractive({ runtime, prompter: { confirm: prompter.confirm, note: prompter.note, }, reason: "Linux installs use a systemd user service by default. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.", requireConfirm: false, }); } const explicitInstallDaemon = typeof opts.installDaemon === "boolean" ? opts.installDaemon : undefined; let installDaemon: boolean; if (explicitInstallDaemon !== undefined) { installDaemon = explicitInstallDaemon; } else if (process.platform === "linux" && !systemdAvailable) { installDaemon = false; } else if (flow === "quickstart") { installDaemon = true; } else { installDaemon = await prompter.confirm({ message: "Install Gateway daemon (recommended)", initialValue: true, }); } if (process.platform === "linux" && !systemdAvailable && installDaemon) { await prompter.note( "Systemd user services are unavailable; skipping daemon install. Use your container supervisor or `docker compose up -d`.", "Gateway daemon", ); installDaemon = false; } if (installDaemon) { const daemonRuntime = flow === "quickstart" ? (DEFAULT_GATEWAY_DAEMON_RUNTIME as GatewayDaemonRuntime) : ((await prompter.select({ message: "Gateway daemon runtime", options: GATEWAY_DAEMON_RUNTIME_OPTIONS, initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME, })) as GatewayDaemonRuntime); if (flow === "quickstart") { await prompter.note( "QuickStart uses Node for the Gateway daemon (stable + supported).", "Gateway daemon runtime", ); } const service = resolveGatewayService(); const loaded = await service.isLoaded({ env: process.env }); if (loaded) { const action = (await prompter.select({ message: "Gateway service already installed", options: [ { value: "restart", label: "Restart" }, { value: "reinstall", label: "Reinstall" }, { value: "skip", label: "Skip" }, ], })) as "restart" | "reinstall" | "skip"; if (action === "restart") { await withWizardProgress( "Gateway daemon", { doneMessage: "Gateway daemon restarted." }, async (progress) => { progress.update("Restarting Gateway daemon…"); await service.restart({ env: process.env, stdout: process.stdout, }); }, ); } else if (action === "reinstall") { await withWizardProgress( "Gateway daemon", { doneMessage: "Gateway daemon uninstalled." }, async (progress) => { progress.update("Uninstalling Gateway daemon…"); await service.uninstall({ env: process.env, stdout: process.stdout }); }, ); } } if (!loaded || (loaded && (await service.isLoaded({ env: process.env })) === false)) { const devMode = process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts"); await withWizardProgress( "Gateway daemon", { doneMessage: "Gateway daemon installed." }, async (progress) => { progress.update("Preparing Gateway daemon…"); const nodePath = await resolvePreferredNodePath({ env: process.env, runtime: daemonRuntime, }); const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({ port: settings.port, dev: devMode, runtime: daemonRuntime, nodePath, }); if (daemonRuntime === "node") { const systemNode = await resolveSystemNodeInfo({ env: process.env }); const warning = renderSystemNodeWarning(systemNode, programArguments[0]); if (warning) await prompter.note(warning, "Gateway runtime"); } const environment = buildServiceEnvironment({ env: process.env, port: settings.port, token: settings.gatewayToken, launchdLabel: process.platform === "darwin" ? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE) : undefined, }); progress.update("Installing Gateway daemon…"); await service.install({ env: process.env, stdout: process.stdout, programArguments, workingDirectory, environment, }); }, ); } } if (!opts.skipHealth) { const probeLinks = resolveControlUiLinks({ bind: nextConfig.gateway?.bind ?? "loopback", port: settings.port, customBindHost: nextConfig.gateway?.customBindHost, basePath: undefined, }); // Daemon install/restart can briefly flap the WS; wait a bit so health check doesn't false-fail. await waitForGatewayReachable({ url: probeLinks.wsUrl, token: settings.gatewayToken, deadlineMs: 15_000, }); try { await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); } catch (err) { runtime.error(formatHealthCheckFailure(err)); await prompter.note( [ "Docs:", "https://docs.clawd.bot/gateway/health", "https://docs.clawd.bot/gateway/troubleshooting", ].join("\n"), "Health check help", ); } } const controlUiEnabled = nextConfig.gateway?.controlUi?.enabled ?? baseConfig.gateway?.controlUi?.enabled ?? true; if (!opts.skipUi && controlUiEnabled) { const controlUiAssets = await ensureControlUiAssetsBuilt(runtime); if (!controlUiAssets.ok && controlUiAssets.message) { runtime.error(controlUiAssets.message); } } await prompter.note( [ "Add nodes for extra features:", "- macOS app (system + notifications)", "- iOS app (camera/canvas)", "- Android app (camera/canvas)", ].join("\n"), "Optional apps", ); const controlUiBasePath = nextConfig.gateway?.controlUi?.basePath ?? baseConfig.gateway?.controlUi?.basePath; const links = resolveControlUiLinks({ bind: settings.bind, port: settings.port, customBindHost: settings.customBindHost, basePath: controlUiBasePath, }); const tokenParam = settings.authMode === "token" && settings.gatewayToken ? `?token=${encodeURIComponent(settings.gatewayToken)}` : ""; const authedUrl = `${links.httpUrl}${tokenParam}`; const gatewayProbe = await probeGatewayReachable({ url: links.wsUrl, token: settings.authMode === "token" ? settings.gatewayToken : undefined, password: settings.authMode === "password" ? nextConfig.gateway?.auth?.password : "", }); const gatewayStatusLine = gatewayProbe.ok ? "Gateway: reachable" : `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`; const bootstrapPath = path.join( resolveUserPath(options.workspaceDir), DEFAULT_BOOTSTRAP_FILENAME, ); const hasBootstrap = await fs .access(bootstrapPath) .then(() => true) .catch(() => false); await prompter.note( [ `Web UI: ${links.httpUrl}`, tokenParam ? `Web UI (with token): ${authedUrl}` : undefined, `Gateway WS: ${links.wsUrl}`, gatewayStatusLine, "Docs: https://docs.clawd.bot/web/control-ui", ] .filter(Boolean) .join("\n"), "Control UI", ); 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!)", ); const wantsTui = await prompter.confirm({ message: "Do you want to hatch your bot now?", initialValue: true, }); if (wantsTui) { 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: "Wake up, my friend!", }); } } else { const browserSupport = await detectBrowserOpenSupport(); if (!browserSupport.ok) { await prompter.note( formatControlUiSshHint({ port: settings.port, basePath: controlUiBasePath, token: settings.authMode === "token" ? settings.gatewayToken : undefined, }), "Open Control UI", ); } else { await prompter.note( "Opening Control UI automatically after onboarding (no extra prompts).", "Open Control UI", ); } } } else if (opts.skipUi) { await prompter.note("Skipping Control UI/TUI prompts.", "Control UI"); } await prompter.note( ["Back up your agent workspace.", "Docs: https://docs.clawd.bot/concepts/agent-workspace"].join( "\n", ), "Workspace backup", ); await prompter.note( "Running agents on your computer is risky — harden your setup: https://docs.clawd.bot/security", "Security", ); const shouldOpenControlUi = !opts.skipUi && settings.authMode === "token" && Boolean(settings.gatewayToken); let controlUiOpened = false; let controlUiOpenHint: string | undefined; if (shouldOpenControlUi) { const browserSupport = await detectBrowserOpenSupport(); if (browserSupport.ok) { controlUiOpened = await openUrl(authedUrl); if (!controlUiOpened) { controlUiOpenHint = formatControlUiSshHint({ port: settings.port, basePath: controlUiBasePath, token: settings.gatewayToken, }); } } else { controlUiOpenHint = formatControlUiSshHint({ port: settings.port, basePath: controlUiBasePath, token: settings.gatewayToken, }); } await prompter.note( [ `Dashboard link (with token): ${authedUrl}`, controlUiOpened ? "Opened in your browser. Keep that tab to control Clawdbot." : "Copy/paste this URL in a browser on this machine to control Clawdbot.", controlUiOpenHint, ] .filter(Boolean) .join("\n"), "Dashboard ready", ); } const webSearchKey = (nextConfig.tools?.web?.search?.apiKey ?? "").trim(); const webSearchEnv = (process.env.BRAVE_API_KEY ?? "").trim(); const hasWebSearchKey = Boolean(webSearchKey || webSearchEnv); await prompter.note( hasWebSearchKey ? [ "Web search is enabled, so your agent can look things up online when needed.", "", webSearchKey ? "API key: stored in config (tools.web.search.apiKey)." : "API key: provided via BRAVE_API_KEY env var (Gateway environment).", "Docs: https://docs.clawd.bot/tools/web", ].join("\n") : [ "If you want your agent to be able to search the web, you’ll need an API key.", "", "Clawdbot uses Brave Search for the `web_search` tool. Without a Brave Search API key, web search won’t work.", "", "Set it up interactively:", "- Run: clawdbot configure --section web", "- Enable web_search and paste your Brave Search API key", "", "Alternative: set BRAVE_API_KEY in the Gateway environment (no config changes).", "Docs: https://docs.clawd.bot/tools/web", ].join("\n"), "Web search (optional)", ); await prompter.outro( controlUiOpened ? "Onboarding complete. Dashboard opened with your token; keep that tab to control Clawdbot." : "Onboarding complete. Use the tokenized dashboard link above to control Clawdbot.", ); }