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 (0.0.0.0)" }, { value: "tailnet", label: "Tailnet (Tailscale IP)" }, { value: "auto", label: "Auto (Loopback → LAN)" }, { value: "custom", label: "Custom IP" }, ], })) as "loopback" | "lan" | "auto" | "custom" | "tailnet") ) as "loopback" | "lan" | "auto" | "custom" | "tailnet"; 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: "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. // - 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 (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 ?? "", }); 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, }, }; }