import fsPromises from "node:fs/promises"; import nodePath from "node:path"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { note } from "../terminal/note.js"; import { resolveUserPath } from "../utils.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; import { WizardCancelledError } from "../wizard/prompts.js"; import { resolveSetupSecretInputString } from "../wizard/setup.secret-input.js"; import { removeChannelConfigWizard } from "./configure.channels.js"; import { maybeInstallDaemon } from "./configure.daemon.js"; import { promptAuthConfig } from "./configure.gateway-auth.js"; import { promptGatewayConfig } from "./configure.gateway.js"; import type { ChannelsWizardMode, ConfigureWizardParams, WizardSection, } from "./configure.shared.js"; import { CONFIGURE_SECTION_OPTIONS, confirm, intro, outro, select, text, } from "./configure.shared.js"; import { formatHealthCheckFailure } from "./health-format.js"; import { healthCommand } from "./health.js"; import { noteChannelStatus, setupChannels } from "./onboard-channels.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, ensureWorkspaceAndSessions, guardCancel, probeGatewayReachable, resolveControlUiLinks, summarizeExistingConfig, waitForGatewayReachable, } from "./onboard-helpers.js"; import { promptRemoteGatewayConfig } from "./onboard-remote.js"; import { setupSkills } from "./onboard-skills.js"; type ConfigureSectionChoice = WizardSection | "__continue"; async function resolveGatewaySecretInputForWizard(params: { cfg: OpenClawConfig; value: unknown; path: string; }): Promise { try { return await resolveSetupSecretInputString({ config: params.cfg, value: params.value, path: params.path, env: process.env, }); } catch { return undefined; } } async function runGatewayHealthCheck(params: { cfg: OpenClawConfig; runtime: RuntimeEnv; port: number; }): Promise { const localLinks = resolveControlUiLinks({ bind: params.cfg.gateway?.bind ?? "loopback", port: params.port, customBindHost: params.cfg.gateway?.customBindHost, basePath: undefined, }); const remoteUrl = params.cfg.gateway?.remote?.url?.trim(); const wsUrl = params.cfg.gateway?.mode === "remote" && remoteUrl ? remoteUrl : localLinks.wsUrl; const configuredToken = await resolveGatewaySecretInputForWizard({ cfg: params.cfg, value: params.cfg.gateway?.auth?.token, path: "gateway.auth.token", }); const configuredPassword = await resolveGatewaySecretInputForWizard({ cfg: params.cfg, value: params.cfg.gateway?.auth?.password, path: "gateway.auth.password", }); const token = process.env.OPENCLAW_GATEWAY_TOKEN ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? configuredToken; const password = process.env.OPENCLAW_GATEWAY_PASSWORD ?? process.env.CLAWDBOT_GATEWAY_PASSWORD ?? configuredPassword; await waitForGatewayReachable({ url: wsUrl, token, password, deadlineMs: 15_000, }); try { await healthCommand({ json: false, timeoutMs: 10_000 }, params.runtime); } catch (err) { params.runtime.error(formatHealthCheckFailure(err)); note( [ "Docs:", "https://docs.openclaw.ai/gateway/health", "https://docs.openclaw.ai/gateway/troubleshooting", ].join("\n"), "Health check help", ); } } async function promptConfigureSection( runtime: RuntimeEnv, hasSelection: boolean, ): Promise { return guardCancel( await select({ message: "Select sections to configure", options: [ ...CONFIGURE_SECTION_OPTIONS, { value: "__continue", label: "Continue", hint: hasSelection ? "Done" : "Skip for now", }, ], initialValue: CONFIGURE_SECTION_OPTIONS[0]?.value, }), runtime, ); } async function promptChannelMode(runtime: RuntimeEnv): Promise { return guardCancel( await select({ message: "Channels", options: [ { value: "configure", label: "Configure/link", hint: "Add/update channels; disable unselected accounts", }, { value: "remove", label: "Remove channel config", hint: "Delete channel tokens/settings from openclaw.json", }, ], initialValue: "configure", }), runtime, ) as ChannelsWizardMode; } async function promptWebToolsConfig( nextConfig: OpenClawConfig, runtime: RuntimeEnv, ): Promise { const existingSearch = nextConfig.tools?.web?.search; const existingFetch = nextConfig.tools?.web?.fetch; const { resolveSearchProviderOptions, resolveExistingKey, hasExistingKey, applySearchKey, applySearchProviderSelection, hasKeyInEnv, } = await import("./onboard-search.js"); const searchProviderOptions = resolveSearchProviderOptions(nextConfig); const defaultProvider = searchProviderOptions[0]?.id; const hasKeyForProvider = (provider: string): boolean => { const entry = searchProviderOptions.find((e) => e.id === provider); if (!entry) { return false; } return hasExistingKey(nextConfig, provider) || hasKeyInEnv(entry); }; const existingProvider = (() => { const stored = existingSearch?.provider; if (stored && searchProviderOptions.some((e) => e.id === stored)) { return stored; } return searchProviderOptions.find((e) => hasKeyForProvider(e.id))?.id ?? defaultProvider; })(); note( [ "Web search lets your agent look things up online using the `web_search` tool.", "Choose a provider and paste your API key.", "Docs: https://docs.openclaw.ai/tools/web", ].join("\n"), "Web search", ); const enableSearch = guardCancel( await confirm({ message: "Enable web_search?", initialValue: existingSearch?.enabled ?? searchProviderOptions.some((e) => hasKeyForProvider(e.id)), }), runtime, ); let nextSearch: Record = { ...existingSearch, enabled: enableSearch, }; let workingConfig = nextConfig; if (enableSearch) { if (searchProviderOptions.length === 0) { note( [ "No web search providers are currently available under this plugin policy.", "Enable plugins or remove deny rules, then rerun configure.", "Docs: https://docs.openclaw.ai/tools/web", ].join("\n"), "Web search", ); nextSearch = { ...existingSearch, enabled: false, }; } else { const providerOptions = searchProviderOptions.map((entry) => { const configured = hasKeyForProvider(entry.id); return { value: entry.id, label: entry.label, hint: configured ? `${entry.hint} · configured` : entry.hint, }; }); const providerChoice = guardCancel( await select({ message: "Choose web search provider", options: providerOptions, initialValue: existingProvider, }), runtime, ); nextSearch = { ...nextSearch, provider: providerChoice }; const entry = searchProviderOptions.find((e) => e.id === providerChoice)!; const credentialLabel = entry.credentialLabel?.trim() || `${entry.label} API key`; const existingKey = resolveExistingKey(nextConfig, providerChoice); const keyConfigured = hasExistingKey(nextConfig, providerChoice); const envAvailable = entry.envVars.some((k) => Boolean(process.env[k]?.trim())); const envVarNames = entry.envVars.join(" / "); const keyInput = guardCancel( await text({ message: keyConfigured ? envAvailable ? `${credentialLabel} (leave blank to keep current or use ${envVarNames})` : `${credentialLabel} (leave blank to keep current)` : envAvailable ? `${credentialLabel} (paste it here; leave blank to use ${envVarNames})` : credentialLabel, placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, }), runtime, ); const key = String(keyInput ?? "").trim(); if (key || existingKey) { workingConfig = applySearchKey(workingConfig, providerChoice, (key || existingKey)!); nextSearch = { ...workingConfig.tools?.web?.search }; } else if (keyConfigured || envAvailable) { workingConfig = applySearchProviderSelection(workingConfig, providerChoice); nextSearch = { ...workingConfig.tools?.web?.search }; } else { nextSearch = { ...nextSearch, provider: providerChoice }; note( [ "No key stored yet — web_search won't work until a key is available.", `Store your ${credentialLabel} here or set ${envVarNames} in the Gateway environment.`, `Get your API key at: ${entry.signupUrl}`, "Docs: https://docs.openclaw.ai/tools/web", ].join("\n"), "Web search", ); } } } const enableFetch = guardCancel( await confirm({ message: "Enable web_fetch (keyless HTTP fetch)?", initialValue: existingFetch?.enabled ?? true, }), runtime, ); const nextFetch = { ...existingFetch, enabled: enableFetch, }; return { ...workingConfig, tools: { ...workingConfig.tools, web: { ...workingConfig.tools?.web, search: nextSearch, fetch: nextFetch, }, }, }; } export async function runConfigureWizard( opts: ConfigureWizardParams, runtime: RuntimeEnv = defaultRuntime, ) { try { intro(opts.command === "update" ? "OpenClaw update wizard" : "OpenClaw configure"); const prompter = createClackPrompter(); const snapshot = await readConfigFileSnapshot(); const baseConfig: OpenClawConfig = snapshot.valid ? snapshot.config : {}; if (snapshot.exists) { const title = snapshot.valid ? "Existing config detected" : "Invalid config"; note(summarizeExistingConfig(baseConfig), title); if (!snapshot.valid && snapshot.issues.length > 0) { note( [ ...snapshot.issues.map((iss) => `- ${iss.path}: ${iss.message}`), "", "Docs: https://docs.openclaw.ai/gateway/configuration", ].join("\n"), "Config issues", ); } if (!snapshot.valid) { outro( `Config invalid. Run \`${formatCliCommand("openclaw doctor")}\` to repair it, then re-run configure.`, ); runtime.exit(1); return; } } const localUrl = "ws://127.0.0.1:18789"; const baseLocalProbeToken = await resolveGatewaySecretInputForWizard({ cfg: baseConfig, value: baseConfig.gateway?.auth?.token, path: "gateway.auth.token", }); const baseLocalProbePassword = await resolveGatewaySecretInputForWizard({ cfg: baseConfig, value: baseConfig.gateway?.auth?.password, path: "gateway.auth.password", }); const localProbe = await probeGatewayReachable({ url: localUrl, token: process.env.OPENCLAW_GATEWAY_TOKEN ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? baseLocalProbeToken, password: process.env.OPENCLAW_GATEWAY_PASSWORD ?? process.env.CLAWDBOT_GATEWAY_PASSWORD ?? baseLocalProbePassword, }); const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? ""; const baseRemoteProbeToken = await resolveGatewaySecretInputForWizard({ cfg: baseConfig, value: baseConfig.gateway?.remote?.token, path: "gateway.remote.token", }); const remoteProbe = remoteUrl ? await probeGatewayReachable({ url: remoteUrl, token: baseRemoteProbeToken, }) : null; const mode = guardCancel( await select({ message: "Where will the Gateway run?", options: [ { value: "local", label: "Local (this machine)", hint: localProbe.ok ? `Gateway reachable (${localUrl})` : `No gateway detected (${localUrl})`, }, { value: "remote", label: "Remote (info-only)", hint: !remoteUrl ? "No remote URL configured yet" : remoteProbe?.ok ? `Gateway reachable (${remoteUrl})` : `Configured but unreachable (${remoteUrl})`, }, ], }), runtime, ); if (mode === "remote") { let remoteConfig = await promptRemoteGatewayConfig(baseConfig, prompter); remoteConfig = applyWizardMetadata(remoteConfig, { command: opts.command, mode, }); await writeConfigFile(remoteConfig); logConfigUpdated(runtime); outro("Remote gateway configured."); return; } let nextConfig = { ...baseConfig }; let didSetGatewayMode = false; if (nextConfig.gateway?.mode !== "local") { nextConfig = { ...nextConfig, gateway: { ...nextConfig.gateway, mode: "local", }, }; didSetGatewayMode = true; } let workspaceDir = nextConfig.agents?.defaults?.workspace ?? baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE; let gatewayPort = resolveGatewayPort(baseConfig); const persistConfig = async () => { nextConfig = applyWizardMetadata(nextConfig, { command: opts.command, mode, }); await writeConfigFile(nextConfig); logConfigUpdated(runtime); }; const configureWorkspace = async () => { const workspaceInput = guardCancel( await text({ message: "Workspace directory", initialValue: workspaceDir, }), runtime, ); workspaceDir = resolveUserPath(String(workspaceInput ?? "").trim() || DEFAULT_WORKSPACE); if (!snapshot.exists) { const indicators = ["MEMORY.md", "memory", ".git"].map((name) => nodePath.join(workspaceDir, name), ); const hasExistingContent = ( await Promise.all( indicators.map(async (candidate) => { try { await fsPromises.access(candidate); return true; } catch { return false; } }), ) ).some(Boolean); if (hasExistingContent) { note( [ `Existing workspace detected at ${workspaceDir}`, "Existing files are preserved. Missing templates may be created, never overwritten.", ].join("\n"), "Existing workspace", ); } } nextConfig = { ...nextConfig, agents: { ...nextConfig.agents, defaults: { ...nextConfig.agents?.defaults, workspace: workspaceDir, }, }, }; await ensureWorkspaceAndSessions(workspaceDir, runtime); }; const configureChannelsSection = async () => { await noteChannelStatus({ cfg: nextConfig, prompter }); const channelMode = await promptChannelMode(runtime); if (channelMode === "configure") { nextConfig = await setupChannels(nextConfig, runtime, prompter, { allowDisable: true, allowSignalInstall: true, skipConfirm: true, skipStatusNote: true, }); } else { nextConfig = await removeChannelConfigWizard(nextConfig, runtime); } }; const promptDaemonPort = async () => { const portInput = guardCancel( await text({ message: "Gateway port for service install", initialValue: String(gatewayPort), validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"), }), runtime, ); gatewayPort = Number.parseInt(String(portInput), 10); }; if (opts.sections) { const selected = opts.sections; if (!selected || selected.length === 0) { outro("No changes selected."); return; } if (selected.includes("workspace")) { await configureWorkspace(); } if (selected.includes("model")) { nextConfig = await promptAuthConfig(nextConfig, runtime, prompter); } if (selected.includes("web")) { nextConfig = await promptWebToolsConfig(nextConfig, runtime); } if (selected.includes("gateway")) { const gateway = await promptGatewayConfig(nextConfig, runtime); nextConfig = gateway.config; gatewayPort = gateway.port; } if (selected.includes("channels")) { await configureChannelsSection(); } if (selected.includes("skills")) { const wsDir = resolveUserPath(workspaceDir); nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); } await persistConfig(); if (selected.includes("daemon")) { if (!selected.includes("gateway")) { await promptDaemonPort(); } await maybeInstallDaemon({ runtime, port: gatewayPort }); } if (selected.includes("health")) { await runGatewayHealthCheck({ cfg: nextConfig, runtime, port: gatewayPort }); } } else { let ranSection = false; let didConfigureGateway = false; while (true) { const choice = await promptConfigureSection(runtime, ranSection); if (choice === "__continue") { break; } ranSection = true; if (choice === "workspace") { await configureWorkspace(); await persistConfig(); } if (choice === "model") { nextConfig = await promptAuthConfig(nextConfig, runtime, prompter); await persistConfig(); } if (choice === "web") { nextConfig = await promptWebToolsConfig(nextConfig, runtime); await persistConfig(); } if (choice === "gateway") { const gateway = await promptGatewayConfig(nextConfig, runtime); nextConfig = gateway.config; gatewayPort = gateway.port; didConfigureGateway = true; await persistConfig(); } if (choice === "channels") { await configureChannelsSection(); await persistConfig(); } if (choice === "skills") { const wsDir = resolveUserPath(workspaceDir); nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); await persistConfig(); } if (choice === "daemon") { if (!didConfigureGateway) { await promptDaemonPort(); } await maybeInstallDaemon({ runtime, port: gatewayPort, }); } if (choice === "health") { await runGatewayHealthCheck({ cfg: nextConfig, runtime, port: gatewayPort }); } } if (!ranSection) { if (didSetGatewayMode) { await persistConfig(); outro("Gateway mode set to local."); return; } outro("No changes selected."); return; } } const controlUiAssets = await ensureControlUiAssetsBuilt(runtime); if (!controlUiAssets.ok && controlUiAssets.message) { runtime.error(controlUiAssets.message); } const bind = nextConfig.gateway?.bind ?? "loopback"; const links = resolveControlUiLinks({ bind, port: gatewayPort, customBindHost: nextConfig.gateway?.customBindHost, basePath: nextConfig.gateway?.controlUi?.basePath, }); // Try both new and old passwords since gateway may still have old config. const newPassword = process.env.OPENCLAW_GATEWAY_PASSWORD ?? process.env.CLAWDBOT_GATEWAY_PASSWORD ?? (await resolveGatewaySecretInputForWizard({ cfg: nextConfig, value: nextConfig.gateway?.auth?.password, path: "gateway.auth.password", })); const oldPassword = process.env.OPENCLAW_GATEWAY_PASSWORD ?? process.env.CLAWDBOT_GATEWAY_PASSWORD ?? (await resolveGatewaySecretInputForWizard({ cfg: baseConfig, value: baseConfig.gateway?.auth?.password, path: "gateway.auth.password", })); const token = process.env.OPENCLAW_GATEWAY_TOKEN ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? (await resolveGatewaySecretInputForWizard({ cfg: nextConfig, value: nextConfig.gateway?.auth?.token, path: "gateway.auth.token", })); let gatewayProbe = await probeGatewayReachable({ url: links.wsUrl, token, password: newPassword, }); // If new password failed and it's different from old password, try old too. if (!gatewayProbe.ok && newPassword !== oldPassword && oldPassword) { gatewayProbe = await probeGatewayReachable({ url: links.wsUrl, token, password: oldPassword, }); } const gatewayStatusLine = gatewayProbe.ok ? "Gateway: reachable" : `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`; note( [ `Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`, gatewayStatusLine, "Docs: https://docs.openclaw.ai/web/control-ui", ].join("\n"), "Control UI", ); outro("Configure complete."); } catch (err) { if (err instanceof WizardCancelledError) { runtime.exit(1); return; } throw err; } }