diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts new file mode 100644 index 000000000..4a7a91c9d --- /dev/null +++ b/src/wizard/onboarding.finalize.ts @@ -0,0 +1,389 @@ +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, + 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, sleep } 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 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, + profile: process.env.CLAWDBOT_PROFILE, + }); + 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 service.restart({ + env: process.env, + profile: process.env.CLAWDBOT_PROFILE, + stdout: process.stdout, + }); + } else if (action === "reinstall") { + await service.uninstall({ env: process.env, stdout: process.stdout }); + } + } + + if ( + !loaded || + (loaded && + (await service.isLoaded({ + env: process.env, + profile: process.env.CLAWDBOT_PROFILE, + })) === false) + ) { + const devMode = + process.argv[1]?.includes(`${path.sep}src${path.sep}`) && + process.argv[1]?.endsWith(".ts"); + 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, + }); + await service.install({ + env: process.env, + stdout: process.stdout, + programArguments, + workingDirectory, + environment, + }); + } + } + + if (!opts.skipHealth) { + await sleep(1500); + 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", + ); + } + + 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.", + ); +} diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts new file mode 100644 index 000000000..a22a9cd19 --- /dev/null +++ b/src/wizard/onboarding.gateway-config.ts @@ -0,0 +1,272 @@ +import { randomToken } from "../commands/onboard-helpers.js"; +import type { GatewayAuthChoice } from "../commands/onboard-types.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import { findTailscaleBinary } from "../infra/tailscale.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { + GatewayWizardSettings, + QuickstartGatewayDefaults, + WizardFlow, +} from "./onboarding.types.js"; +import type { WizardPrompter } from "./prompts.js"; + +type ConfigureGatewayOptions = { + flow: WizardFlow; + baseConfig: ClawdbotConfig; + nextConfig: ClawdbotConfig; + localPort: number; + quickstartGateway: QuickstartGatewayDefaults; + prompter: WizardPrompter; + runtime: RuntimeEnv; +}; + +type ConfigureGatewayResult = { + nextConfig: ClawdbotConfig; + settings: GatewayWizardSettings; +}; + +export async function configureGatewayForOnboarding( + opts: ConfigureGatewayOptions, +): Promise { + const { flow, localPort, quickstartGateway, prompter } = opts; + let { nextConfig } = opts; + + const port = + flow === "quickstart" + ? quickstartGateway.port + : Number.parseInt( + String( + await prompter.text({ + message: "Gateway port", + initialValue: String(localPort), + validate: (value) => + Number.isFinite(Number(value)) ? undefined : "Invalid port", + }), + ), + 10, + ); + + let bind = ( + flow === "quickstart" + ? quickstartGateway.bind + : ((await prompter.select({ + message: "Gateway bind", + options: [ + { value: "loopback", label: "Loopback (127.0.0.1)" }, + { value: "lan", label: "LAN" }, + { value: "auto", label: "Auto" }, + { value: "custom", label: "Custom IP" }, + ], + })) as "loopback" | "lan" | "auto" | "custom") + ) as "loopback" | "lan" | "auto" | "custom"; + + let customBindHost = quickstartGateway.customBindHost; + if (bind === "custom") { + const needsPrompt = flow !== "quickstart" || !customBindHost; + if (needsPrompt) { + const input = await prompter.text({ + message: "Custom IP address", + placeholder: "192.168.1.100", + initialValue: customBindHost ?? "", + validate: (value) => { + if (!value) return "IP address is required for custom bind mode"; + const trimmed = value.trim(); + const parts = trimmed.split("."); + if (parts.length !== 4) + return "Invalid IPv4 address (e.g., 192.168.1.100)"; + if ( + parts.every((part) => { + const n = parseInt(part, 10); + return ( + !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n) + ); + }) + ) + return undefined; + return "Invalid IPv4 address (each octet must be 0-255)"; + }, + }); + customBindHost = typeof input === "string" ? input.trim() : undefined; + } + } + + let authMode = ( + flow === "quickstart" + ? quickstartGateway.authMode + : ((await prompter.select({ + message: "Gateway auth", + options: [ + { + value: "off", + label: "Off (loopback only)", + hint: "Not recommended unless you fully trust local processes", + }, + { + value: "token", + label: "Token", + hint: "Recommended default (local + remote)", + }, + { value: "password", label: "Password" }, + ], + initialValue: "token", + })) as GatewayAuthChoice) + ) as GatewayAuthChoice; + + const tailscaleMode = ( + flow === "quickstart" + ? quickstartGateway.tailscaleMode + : ((await prompter.select({ + message: "Tailscale exposure", + options: [ + { value: "off", label: "Off", hint: "No Tailscale exposure" }, + { + value: "serve", + label: "Serve", + hint: "Private HTTPS for your tailnet (devices on Tailscale)", + }, + { + value: "funnel", + label: "Funnel", + hint: "Public HTTPS via Tailscale Funnel (internet)", + }, + ], + })) as "off" | "serve" | "funnel") + ) as "off" | "serve" | "funnel"; + + // Detect Tailscale binary before proceeding with serve/funnel setup. + if (tailscaleMode !== "off") { + const tailscaleBin = await findTailscaleBinary(); + if (!tailscaleBin) { + await prompter.note( + [ + "Tailscale binary not found in PATH or /Applications.", + "Ensure Tailscale is installed from:", + " https://tailscale.com/download/mac", + "", + "You can continue setup, but serve/funnel will fail at runtime.", + ].join("\n"), + "Tailscale Warning", + ); + } + } + + let tailscaleResetOnExit = + flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false; + if (tailscaleMode !== "off" && flow !== "quickstart") { + await prompter.note( + [ + "Docs:", + "https://docs.clawd.bot/gateway/tailscale", + "https://docs.clawd.bot/web", + ].join("\n"), + "Tailscale", + ); + tailscaleResetOnExit = Boolean( + await prompter.confirm({ + message: "Reset Tailscale serve/funnel on exit?", + initialValue: false, + }), + ); + } + + // Safety + constraints: + // - Tailscale wants bind=loopback so we never expose a non-loopback server + tailscale serve/funnel at once. + // - Auth off only allowed for bind=loopback. + // - Funnel requires password auth. + if (tailscaleMode !== "off" && bind !== "loopback") { + await prompter.note( + "Tailscale requires bind=loopback. Adjusting bind to loopback.", + "Note", + ); + bind = "loopback"; + customBindHost = undefined; + } + + if (authMode === "off" && bind !== "loopback") { + await prompter.note( + "Non-loopback bind requires auth. Switching to token auth.", + "Note", + ); + authMode = "token"; + } + + if (tailscaleMode === "funnel" && authMode !== "password") { + await prompter.note("Tailscale funnel requires password auth.", "Note"); + authMode = "password"; + } + + let gatewayToken: string | undefined; + if (authMode === "token") { + if (flow === "quickstart") { + gatewayToken = quickstartGateway.token ?? randomToken(); + } else { + const tokenInput = await prompter.text({ + message: "Gateway token (blank to generate)", + placeholder: "Needed for multi-machine or non-loopback access", + initialValue: quickstartGateway.token ?? randomToken(), + }); + gatewayToken = String(tokenInput).trim() || randomToken(); + } + } + + if (authMode === "password") { + const password = + flow === "quickstart" && quickstartGateway.password + ? quickstartGateway.password + : await prompter.text({ + message: "Gateway password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { + ...nextConfig.gateway?.auth, + mode: "password", + password: String(password).trim(), + }, + }, + }; + } else if (authMode === "token") { + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { + ...nextConfig.gateway?.auth, + mode: "token", + token: gatewayToken, + }, + }, + }; + } + + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + port, + bind, + ...(bind === "custom" && customBindHost ? { customBindHost } : {}), + tailscale: { + ...nextConfig.gateway?.tailscale, + mode: tailscaleMode, + resetOnExit: tailscaleResetOnExit, + }, + }, + }; + + return { + nextConfig, + settings: { + port, + bind, + customBindHost: bind === "custom" ? customBindHost : undefined, + authMode, + gatewayToken, + tailscaleMode, + tailscaleResetOnExit, + }, + }; +} diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 782734f0e..cc0ca9db6 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -1,7 +1,4 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; -import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import { applyAuthChoice, @@ -9,13 +6,6 @@ import { warnIfModelConfigLooksOff, } from "../commands/auth-choice.js"; import { promptAuthChoiceGrouped } from "../commands/auth-choice-prompt.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 { applyPrimaryModel, promptDefaultModel, @@ -24,15 +14,10 @@ import { setupChannels } from "../commands/onboard-channels.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, - detectBrowserOpenSupport, ensureWorkspaceAndSessions, - formatControlUiSshHint, handleReset, - openUrl, printWizardHeader, probeGatewayReachable, - randomToken, - resolveControlUiLinks, summarizeExistingConfig, } from "../commands/onboard-helpers.js"; import { promptRemoteGatewayConfig } from "../commands/onboard-remote.js"; @@ -43,7 +28,6 @@ import type { OnboardOptions, ResetScope, } from "../commands/onboard-types.js"; -import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.js"; import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, @@ -52,22 +36,15 @@ import { resolveGatewayPort, writeConfigFile, } 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 { findTailscaleBinary } from "../infra/tailscale.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; -import { runTui } from "../tui/tui.js"; -import { resolveUserPath, sleep } from "../utils.js"; +import { resolveUserPath } from "../utils.js"; +import { finalizeOnboardingWizard } from "./onboarding.finalize.js"; +import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js"; +import type { + QuickstartGatewayDefaults, + WizardFlow, +} from "./onboarding.types.js"; import type { WizardPrompter } from "./prompts.js"; export async function runOnboardingWizard( @@ -138,17 +115,21 @@ export async function runOnboardingWizard( const quickstartHint = "Configure details later via clawdbot configure."; const advancedHint = "Configure port, network, Tailscale, and auth options."; - const explicitFlow = opts.flow?.trim(); + const explicitFlowRaw = opts.flow?.trim(); if ( - explicitFlow && - explicitFlow !== "quickstart" && - explicitFlow !== "advanced" + explicitFlowRaw && + explicitFlowRaw !== "quickstart" && + explicitFlowRaw !== "advanced" ) { runtime.error("Invalid --flow (use quickstart or advanced)."); runtime.exit(1); return; } - let flow = + const explicitFlow: WizardFlow | undefined = + explicitFlowRaw === "quickstart" || explicitFlowRaw === "advanced" + ? explicitFlowRaw + : undefined; + let flow: WizardFlow = explicitFlow ?? ((await prompter.select({ message: "Onboarding mode", @@ -167,7 +148,7 @@ export async function runOnboardingWizard( flow = "advanced"; } - const quickstartGateway = (() => { + const quickstartGateway: QuickstartGatewayDefaults = (() => { const hasExisting = typeof baseConfig.gateway?.port === "number" || baseConfig.gateway?.bind !== undefined || @@ -380,227 +361,17 @@ export async function runOnboardingWizard( await warnIfModelConfigLooksOff(nextConfig, prompter); - const port = - flow === "quickstart" - ? quickstartGateway.port - : Number.parseInt( - String( - await prompter.text({ - message: "Gateway port", - initialValue: String(localPort), - validate: (value) => - Number.isFinite(Number(value)) ? undefined : "Invalid port", - }), - ), - 10, - ); - - let bind = ( - flow === "quickstart" - ? quickstartGateway.bind - : ((await prompter.select({ - message: "Gateway bind", - options: [ - { value: "loopback", label: "Loopback (127.0.0.1)" }, - { value: "lan", label: "LAN" }, - { value: "auto", label: "Auto" }, - { value: "custom", label: "Custom IP" }, - ], - })) as "loopback" | "lan" | "auto" | "custom") - ) as "loopback" | "lan" | "auto" | "custom"; - - let customBindHost = quickstartGateway.customBindHost; - if (bind === "custom") { - const needsPrompt = flow !== "quickstart" || !customBindHost; - if (needsPrompt) { - const input = await prompter.text({ - message: "Custom IP address", - placeholder: "192.168.1.100", - initialValue: customBindHost ?? "", - validate: (value) => { - if (!value) return "IP address is required for custom bind mode"; - const trimmed = value.trim(); - const parts = trimmed.split("."); - if (parts.length !== 4) - return "Invalid IPv4 address (e.g., 192.168.1.100)"; - if ( - parts.every((part) => { - const n = parseInt(part, 10); - return ( - !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n) - ); - }) - ) - return undefined; - return "Invalid IPv4 address (each octet must be 0-255)"; - }, - }); - customBindHost = typeof input === "string" ? input.trim() : undefined; - } - } - - let authMode = ( - flow === "quickstart" - ? quickstartGateway.authMode - : ((await prompter.select({ - message: "Gateway auth", - options: [ - { - value: "off", - label: "Off (loopback only)", - hint: "Not recommended unless you fully trust local processes", - }, - { - value: "token", - label: "Token", - hint: "Recommended default (local + remote)", - }, - { value: "password", label: "Password" }, - ], - initialValue: "token", - })) as GatewayAuthChoice) - ) as GatewayAuthChoice; - - const tailscaleMode = ( - flow === "quickstart" - ? quickstartGateway.tailscaleMode - : ((await prompter.select({ - message: "Tailscale exposure", - options: [ - { value: "off", label: "Off", hint: "No Tailscale exposure" }, - { - value: "serve", - label: "Serve", - hint: "Private HTTPS for your tailnet (devices on Tailscale)", - }, - { - value: "funnel", - label: "Funnel", - hint: "Public HTTPS via Tailscale Funnel (internet)", - }, - ], - })) as "off" | "serve" | "funnel") - ) as "off" | "serve" | "funnel"; - - // Detect Tailscale binary before proceeding with serve/funnel setup - if (tailscaleMode !== "off") { - const tailscaleBin = await findTailscaleBinary(); - if (!tailscaleBin) { - await prompter.note( - [ - "Tailscale binary not found in PATH or /Applications.", - "Ensure Tailscale is installed from:", - " https://tailscale.com/download/mac", - "", - "You can continue setup, but serve/funnel will fail at runtime.", - ].join("\n"), - "Tailscale Warning", - ); - } - } - - let tailscaleResetOnExit = - flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false; - if (tailscaleMode !== "off" && flow !== "quickstart") { - await prompter.note( - [ - "Docs:", - "https://docs.clawd.bot/gateway/tailscale", - "https://docs.clawd.bot/web", - ].join("\n"), - "Tailscale", - ); - tailscaleResetOnExit = Boolean( - await prompter.confirm({ - message: "Reset Tailscale serve/funnel on exit?", - initialValue: false, - }), - ); - } - - if (tailscaleMode !== "off" && bind !== "loopback") { - await prompter.note( - "Tailscale requires bind=loopback. Adjusting bind to loopback.", - "Note", - ); - bind = "loopback"; - customBindHost = undefined; - } - - if (authMode === "off" && bind !== "loopback") { - await prompter.note( - "Non-loopback bind requires auth. Switching to token auth.", - "Note", - ); - authMode = "token"; - } - - if (tailscaleMode === "funnel" && authMode !== "password") { - await prompter.note("Tailscale funnel requires password auth.", "Note"); - authMode = "password"; - } - - let gatewayToken: string | undefined; - if (authMode === "token") { - if (flow === "quickstart") { - gatewayToken = quickstartGateway.token ?? randomToken(); - } else { - const tokenInput = await prompter.text({ - message: "Gateway token (blank to generate)", - placeholder: "Needed for multi-machine or non-loopback access", - initialValue: quickstartGateway.token ?? randomToken(), - }); - gatewayToken = String(tokenInput).trim() || randomToken(); - } - } - - if (authMode === "password") { - const password = - flow === "quickstart" && quickstartGateway.password - ? quickstartGateway.password - : await prompter.text({ - message: "Gateway password", - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - auth: { - ...nextConfig.gateway?.auth, - mode: "password", - password: String(password).trim(), - }, - }, - }; - } else if (authMode === "token") { - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - auth: { - ...nextConfig.gateway?.auth, - mode: "token", - token: gatewayToken, - }, - }, - }; - } - - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - port, - bind, - ...(bind === "custom" && customBindHost ? { customBindHost } : {}), - tailscale: { - ...nextConfig.gateway?.tailscale, - mode: tailscaleMode, - resetOnExit: tailscaleResetOnExit, - }, - }, - }; + const gateway = await configureGatewayForOnboarding({ + flow, + baseConfig, + nextConfig, + localPort, + quickstartGateway, + prompter, + runtime, + }); + nextConfig = gateway.nextConfig; + const settings = gateway.settings; if (opts.skipChannels ?? opts.skipProviders) { await prompter.note("Skipping channel setup.", "Channels"); @@ -634,321 +405,14 @@ export async function runOnboardingWizard( nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); await writeConfigFile(nextConfig); - 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) { - 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, - profile: process.env.CLAWDBOT_PROFILE, - }); - 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 service.restart({ - env: process.env, - profile: process.env.CLAWDBOT_PROFILE, - stdout: process.stdout, - }); - } else if (action === "reinstall") { - await service.uninstall({ env: process.env, stdout: process.stdout }); - } - } - - if ( - !loaded || - (loaded && - (await service.isLoaded({ - env: process.env, - profile: process.env.CLAWDBOT_PROFILE, - })) === false) - ) { - const devMode = - process.argv[1]?.includes(`${path.sep}src${path.sep}`) && - process.argv[1]?.endsWith(".ts"); - const nodePath = await resolvePreferredNodePath({ - env: process.env, - runtime: daemonRuntime, - }); - const { programArguments, workingDirectory } = - await resolveGatewayProgramArguments({ - 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, - token: gatewayToken, - launchdLabel: - process.platform === "darwin" - ? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE) - : undefined, - }); - await service.install({ - env: process.env, - stdout: process.stdout, - programArguments, - workingDirectory, - environment, - }); - } - } - - if (!opts.skipHealth) { - await sleep(1500); - 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, - port, - customBindHost, - basePath: controlUiBasePath, + await finalizeOnboardingWizard({ + flow, + opts, + baseConfig, + nextConfig, + workspaceDir, + settings, + prompter, + runtime, }); - const tokenParam = - authMode === "token" && gatewayToken - ? `?token=${encodeURIComponent(gatewayToken)}` - : ""; - const authedUrl = `${links.httpUrl}${tokenParam}`; - const gatewayProbe = await probeGatewayReachable({ - url: links.wsUrl, - token: authMode === "token" ? gatewayToken : undefined, - password: authMode === "password" ? nextConfig.gateway?.auth?.password : "", - }); - const gatewayStatusLine = gatewayProbe.ok - ? "Gateway: reachable" - : `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`; - const bootstrapPath = path.join(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: authMode === "token" ? gatewayToken : undefined, - password: - authMode === "password" ? baseConfig.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, - basePath: controlUiBasePath, - token: authMode === "token" ? 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 && authMode === "token" && Boolean(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, - basePath: controlUiBasePath, - token: gatewayToken, - }); - } - } else { - controlUiOpenHint = formatControlUiSshHint({ - port, - basePath: controlUiBasePath, - token: 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", - ); - } - - 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.", - ); } diff --git a/src/wizard/onboarding.types.ts b/src/wizard/onboarding.types.ts new file mode 100644 index 000000000..f99ad16de --- /dev/null +++ b/src/wizard/onboarding.types.ts @@ -0,0 +1,25 @@ +import type { GatewayAuthChoice } from "../commands/onboard-types.js"; + +export type WizardFlow = "quickstart" | "advanced"; + +export type QuickstartGatewayDefaults = { + hasExisting: boolean; + port: number; + bind: "loopback" | "lan" | "auto" | "custom"; + authMode: GatewayAuthChoice; + tailscaleMode: "off" | "serve" | "funnel"; + token?: string; + password?: string; + customBindHost?: string; + tailscaleResetOnExit: boolean; +}; + +export type GatewayWizardSettings = { + port: number; + bind: "loopback" | "lan" | "auto" | "custom"; + customBindHost?: string; + authMode: GatewayAuthChoice; + gatewayToken?: string; + tailscaleMode: "off" | "serve" | "funnel"; + tailscaleResetOnExit: boolean; +};