Files
openclaw/src/wizard/setup.finalize.ts
2026-05-15 15:33:27 +01:00

716 lines
25 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { describeCodexNativeWebSearch } from "../agents/codex-native-web-search.shared.js";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js";
import { formatCliCommand } from "../cli/command-format.js";
import {
buildGatewayInstallPlan,
gatewayInstallErrorHint,
} from "../commands/daemon-install-helpers.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
} from "../commands/daemon-runtime.js";
import { resolveGatewayInstallToken } from "../commands/gateway-install-token.js";
import { formatHealthCheckFailure } from "../commands/health-format.js";
import { healthCommand } from "../commands/health.js";
import {
detectBrowserOpenSupport,
formatControlUiSshHint,
openUrl,
probeGatewayReachable,
waitForGatewayReachable,
resolveControlUiLinks,
} from "../commands/onboard-helpers.js";
import type { OnboardOptions } from "../commands/onboard-types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js";
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js";
import { restoreTerminalState } from "../terminal/restore.js";
import { launchTuiCli } from "../tui/tui-launch.js";
import { resolveUserPath } from "../utils.js";
import { listConfiguredWebSearchProviders } from "../web-search/runtime.js";
import { t } from "./i18n/index.js";
import type { WizardPrompter } from "./prompts.js";
import { setupWizardShellCompletion } from "./setup.completion.js";
import { resolveSetupSecretInputString } from "./setup.secret-input.js";
import type { GatewayWizardSettings, WizardFlow } from "./setup.types.js";
type FinalizeOnboardingOptions = {
flow: WizardFlow;
opts: OnboardOptions;
baseConfig: OpenClawConfig;
nextConfig: OpenClawConfig;
workspaceDir: string;
settings: GatewayWizardSettings;
prompter: WizardPrompter;
runtime: RuntimeEnv;
};
type OnboardSearchModule = typeof import("../commands/onboard-search.js");
let onboardSearchModulePromise: Promise<OnboardSearchModule> | undefined;
const HATCH_TUI_TIMEOUT_MS = 5 * 60 * 1000;
function getLocalizedGatewayDaemonRuntimeOptions() {
return GATEWAY_DAEMON_RUNTIME_OPTIONS.map((option) => ({
hint:
option.value === "node"
? t("wizard.finalize.daemonRuntimeNodeHint")
: (option.hint ?? undefined),
label: option.value === "node" ? t("wizard.finalize.daemonRuntimeNode") : option.label,
value: option.value,
}));
}
function loadOnboardSearchModule(): Promise<OnboardSearchModule> {
onboardSearchModulePromise ??= import("../commands/onboard-search.js");
return onboardSearchModulePromise;
}
export async function finalizeSetupWizard(
options: FinalizeOnboardingOptions,
): Promise<{ launchedTui: boolean }> {
const { flow, opts, baseConfig, nextConfig, settings, prompter, runtime } = options;
let gatewayProbe: { ok: boolean; detail?: string } = { ok: true };
let resolvedGatewayPassword = "";
const withWizardProgress = async <T>(
label: string,
options: { doneMessage?: string | (() => string | undefined) },
work: (progress: { update: (message: string) => void }) => Promise<T>,
): Promise<T> => {
const progress = prompter.progress(label);
try {
return await work(progress);
} finally {
progress.stop(
typeof options.doneMessage === "function" ? options.doneMessage() : options.doneMessage,
);
}
};
const systemdAvailable =
process.platform === "linux" ? await isSystemdUserServiceAvailable() : true;
if (process.platform === "linux" && !systemdAvailable) {
await prompter.note(t("wizard.finalize.systemdUnavailable"), "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: t("wizard.finalize.systemdLingerReason"),
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: t("wizard.finalize.installGateway"),
initialValue: true,
});
}
if (process.platform === "linux" && !systemdAvailable && installDaemon) {
await prompter.note(
t("wizard.finalize.systemdInstallSkipped"),
t("wizard.finalize.gatewayService"),
);
installDaemon = false;
}
if (installDaemon) {
const daemonRuntime =
flow === "quickstart"
? DEFAULT_GATEWAY_DAEMON_RUNTIME
: await prompter.select({
message: t("wizard.finalize.daemonRuntime"),
options: getLocalizedGatewayDaemonRuntimeOptions(),
initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME,
});
if (flow === "quickstart") {
await prompter.note(
t("wizard.finalize.quickstartNodeRuntime"),
t("wizard.finalize.daemonRuntime"),
);
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
let restartWasScheduled = false;
if (loaded) {
const action = await prompter.select({
message: t("wizard.finalize.alreadyInstalled"),
options: [
{ value: "restart", label: t("wizard.finalize.restart") },
{ value: "reinstall", label: t("wizard.finalize.reinstall") },
{ value: "skip", label: t("common.skip") },
],
});
if (action === "restart") {
let restartDoneMessage = t("wizard.finalize.gatewayServiceRestarted");
await withWizardProgress(
t("wizard.finalize.gatewayService"),
{ doneMessage: () => restartDoneMessage },
async (progress) => {
progress.update(t("wizard.finalize.gatewayServiceRestarting"));
const restartResult = await service.restart({
env: process.env,
stdout: process.stdout,
});
const restartStatus = describeGatewayServiceRestart("Gateway", restartResult);
restartDoneMessage = restartStatus.scheduled
? t("wizard.finalize.gatewayServiceRestartScheduled")
: t("wizard.finalize.gatewayServiceRestarted");
restartWasScheduled = restartStatus.scheduled;
},
);
} else if (action === "reinstall") {
await withWizardProgress(
t("wizard.finalize.gatewayService"),
{ doneMessage: t("wizard.finalize.gatewayServiceUninstalled") },
async (progress) => {
progress.update(t("wizard.finalize.gatewayServiceUninstalling"));
await service.uninstall({ env: process.env, stdout: process.stdout });
},
);
}
}
if (
!loaded ||
(!restartWasScheduled && loaded && !(await service.isLoaded({ env: process.env })))
) {
const progress = prompter.progress(t("wizard.finalize.gatewayService"));
let installError: string | null = null;
try {
progress.update(t("wizard.finalize.gatewayServicePreparing"));
const tokenResolution = await resolveGatewayInstallToken({
config: nextConfig,
env: process.env,
});
for (const warning of tokenResolution.warnings) {
await prompter.note(warning, "Gateway service");
}
if (tokenResolution.unavailableReason) {
installError = [
t("wizard.finalize.gatewayInstallBlocked"),
tokenResolution.unavailableReason,
t("wizard.finalize.gatewayInstallFixAuth"),
].join(" ");
} else {
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan(
{
env: process.env,
port: settings.port,
runtime: daemonRuntime,
warn: (message, title) => prompter.note(message, title),
config: nextConfig,
},
);
progress.update(t("wizard.finalize.gatewayServiceInstalling"));
await service.install({
env: process.env,
stdout: process.stdout,
programArguments,
workingDirectory,
environment,
});
}
} catch (err) {
installError = formatErrorMessage(err);
} finally {
progress.stop(
installError
? t("wizard.finalize.gatewayServiceInstallFailed")
: t("wizard.finalize.gatewayServiceInstalled"),
);
}
if (installError) {
await prompter.note(
t("wizard.finalize.gatewayServiceInstallFailedWithError", { error: installError }),
"Gateway",
);
await prompter.note(gatewayInstallErrorHint(), "Gateway");
}
}
}
if (settings.authMode === "password") {
try {
resolvedGatewayPassword =
(await resolveSetupSecretInputString({
config: nextConfig,
value: nextConfig.gateway?.auth?.password,
path: "gateway.auth.password",
env: process.env,
})) ?? "";
} catch (error) {
await prompter.note(
[
t("wizard.finalize.secretRefAuthFailed", { field: "gateway.auth.password" }),
formatErrorMessage(error),
].join("\n"),
t("wizard.gateway.auth"),
);
}
}
if (!opts.skipHealth) {
const probeLinks = resolveControlUiLinks({
bind: nextConfig.gateway?.bind ?? "loopback",
port: settings.port,
customBindHost: nextConfig.gateway?.customBindHost,
basePath: undefined,
tlsEnabled: nextConfig.gateway?.tls?.enabled === true,
});
// Daemon install/restart can briefly flap the WS; wait a bit so health check doesn't false-fail.
gatewayProbe = await waitForGatewayReachable({
url: probeLinks.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
password: settings.authMode === "password" ? resolvedGatewayPassword : undefined,
deadlineMs: 15_000,
});
if (gatewayProbe.ok) {
try {
const healthConfig: OpenClawConfig =
settings.authMode === "token" && settings.gatewayToken
? {
...nextConfig,
gateway: {
...nextConfig.gateway,
auth: {
...nextConfig.gateway?.auth,
mode: "token",
token: settings.gatewayToken,
},
},
}
: nextConfig;
await healthCommand(
{
json: false,
timeoutMs: 10_000,
config: healthConfig,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
password: settings.authMode === "password" ? resolvedGatewayPassword : undefined,
},
runtime,
);
} catch (err) {
runtime.error(formatHealthCheckFailure(err));
await prompter.note(
[
t("common.docs"),
"https://docs.openclaw.ai/gateway/health",
"https://docs.openclaw.ai/gateway/troubleshooting",
].join("\n"),
t("wizard.finalize.healthCheckHelp"),
);
}
} else if (installDaemon) {
runtime.error(
formatHealthCheckFailure(
new Error(
gatewayProbe.detail ?? `gateway did not become reachable at ${probeLinks.wsUrl}`,
),
),
);
await prompter.note(
[
t("common.docs"),
"https://docs.openclaw.ai/gateway/health",
"https://docs.openclaw.ai/gateway/troubleshooting",
].join("\n"),
t("wizard.finalize.healthCheckHelp"),
);
} else {
await prompter.note(
[
t("wizard.finalize.gatewayNotDetected"),
t("wizard.finalize.noBackgroundGatewayExpected"),
t("wizard.finalize.startGatewayNow", {
command: formatCliCommand("openclaw gateway run"),
}),
t("wizard.finalize.rerunInstallDaemon", {
command: formatCliCommand("openclaw onboard --install-daemon"),
}),
t("wizard.finalize.skipHealthNextTime", {
command: formatCliCommand("openclaw onboard --skip-health"),
}),
].join("\n"),
"Gateway",
);
}
}
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(
[
t("wizard.finalize.addNodes"),
`- ${t("wizard.finalize.nodeMac")}`,
`- ${t("wizard.finalize.nodeIos")}`,
`- ${t("wizard.finalize.nodeAndroid")}`,
].join("\n"),
t("wizard.finalize.optionalApps"),
);
const controlUiBasePath =
nextConfig.gateway?.controlUi?.basePath ?? baseConfig.gateway?.controlUi?.basePath;
const links = resolveControlUiLinks({
bind: settings.bind,
port: settings.port,
customBindHost: settings.customBindHost,
basePath: controlUiBasePath,
tlsEnabled: nextConfig.gateway?.tls?.enabled === true,
});
const authedUrl =
settings.authMode === "token" && settings.gatewayToken
? `${links.httpUrl}#token=${encodeURIComponent(settings.gatewayToken)}`
: links.httpUrl;
if (opts.skipHealth || !gatewayProbe.ok) {
gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
password: settings.authMode === "password" ? resolvedGatewayPassword : "",
});
}
const gatewayStatusLine = gatewayProbe.ok
? t("wizard.finalize.gatewayReachable")
: t("wizard.finalize.gatewayNotDetectedStatus", {
detail: 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(
[
t("wizard.finalize.webUiUrl", { url: links.httpUrl }),
settings.authMode === "token" && settings.gatewayToken
? t("wizard.finalize.webUiWithTokenUrl", { url: authedUrl })
: undefined,
t("wizard.finalize.gatewayWsUrl", { url: links.wsUrl }),
gatewayStatusLine,
t("wizard.finalize.controlUiDocs"),
]
.filter(Boolean)
.join("\n"),
"Control UI",
);
let controlUiOpened = false;
let controlUiOpenHint: string | undefined;
let seededInBackground = false;
let hatchChoice: "tui" | "web" | "later" | null = null;
let launchedTui = false;
if (!opts.skipUi) {
if (hasBootstrap) {
await prompter.note(
[
t("wizard.finalize.workspaceReady"),
t("wizard.finalize.firstTerminalChat"),
t("wizard.finalize.editBootstrap"),
].join("\n"),
t("wizard.finalize.hatchYourAgent"),
);
}
if (gatewayProbe.ok) {
await prompter.note(
[
t("wizard.finalize.gatewayTokenShared"),
t("wizard.finalize.gatewayTokenStored"),
t("wizard.finalize.gatewayTokenView", {
command: formatCliCommand("openclaw config get gateway.auth.token"),
}),
t("wizard.finalize.gatewayTokenGenerate", {
command: formatCliCommand("openclaw doctor --generate-gateway-token"),
}),
t("wizard.finalize.dashboardTokenMemory"),
t("wizard.finalize.dashboardOpenAnytime", {
command: formatCliCommand("openclaw dashboard --no-open"),
}),
t("wizard.finalize.dashboardTokenPrompt"),
].join("\n"),
"Token",
);
}
const hatchOptions: { value: "tui" | "web" | "later"; label: string }[] = [
{ value: "tui", label: t("wizard.finalize.terminalHatch") },
...(gatewayProbe.ok
? [{ value: "web" as const, label: t("wizard.finalize.browserHatch") }]
: []),
{ value: "later", label: t("wizard.finalize.hatchLater") },
];
hatchChoice = await prompter.select({
message: t("wizard.finalize.hatchPrompt"),
options: hatchOptions,
initialValue: "tui",
});
if (hatchChoice === "tui") {
restoreTerminalState("pre-setup tui", { resumeStdinIfPaused: true });
try {
await launchTuiCli({
local: true,
deliver: false,
message: hasBootstrap ? t("wizard.finalize.bootstrapHatchMessage") : undefined,
timeoutMs: HATCH_TUI_TIMEOUT_MS,
});
} finally {
restoreTerminalState("post-setup tui", { resumeStdinIfPaused: true });
}
launchedTui = true;
} else if (hatchChoice === "web") {
const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) {
controlUiOpened = await openUrl(authedUrl);
if (!controlUiOpened) {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
basePath: controlUiBasePath,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
});
}
} else {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
basePath: controlUiBasePath,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
});
}
await prompter.note(
[
t("wizard.finalize.dashboardLinkWithToken", { url: authedUrl }),
controlUiOpened
? t("wizard.finalize.dashboardOpened")
: t("wizard.finalize.dashboardCopyPaste"),
controlUiOpenHint,
]
.filter(Boolean)
.join("\n"),
t("wizard.finalize.dashboardReady"),
);
} else {
await prompter.note(
t("wizard.finalize.dashboardWhenReady", {
command: formatCliCommand("openclaw dashboard --no-open"),
}),
t("wizard.finalize.laterTitle"),
);
}
} else if (opts.skipUi) {
await prompter.note(t("wizard.finalize.skipControlUi"), t("wizard.finalize.controlUiTitle"));
}
await prompter.note(
[t("wizard.finalize.backupWorkspace"), t("wizard.finalize.workspaceDocs")].join("\n"),
t("wizard.finalize.workspaceBackupTitle"),
);
await prompter.note(t("wizard.finalize.securityReminder"), t("wizard.security.title"));
await setupWizardShellCompletion({ flow, prompter });
const shouldOpenControlUi =
!opts.skipUi &&
gatewayProbe.ok &&
settings.authMode === "token" &&
Boolean(settings.gatewayToken) &&
hatchChoice === null;
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(
[
t("wizard.finalize.dashboardLinkWithToken", { url: authedUrl }),
controlUiOpened
? t("wizard.finalize.dashboardOpened")
: t("wizard.finalize.dashboardCopyPaste"),
controlUiOpenHint,
]
.filter(Boolean)
.join("\n"),
t("wizard.finalize.dashboardReady"),
);
}
const codexNativeSummary = describeCodexNativeWebSearch(nextConfig);
const webSearchProvider = nextConfig.tools?.web?.search?.provider;
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
const configuredSearchProviders = listConfiguredWebSearchProviders({ config: nextConfig });
if (webSearchProvider) {
const { resolveExistingKey, hasExistingKey, hasKeyInEnv } = await loadOnboardSearchModule();
const entry = configuredSearchProviders.find((e) => e.id === webSearchProvider);
const label = entry?.label ?? webSearchProvider;
const storedKey = entry ? resolveExistingKey(nextConfig, webSearchProvider) : undefined;
const keyConfigured = entry ? hasExistingKey(nextConfig, webSearchProvider) : false;
const envAvailable = entry ? hasKeyInEnv(entry) : false;
const hasKey = keyConfigured || envAvailable;
const keySource = storedKey
? t("wizard.finalize.webSearchKeyStored")
: keyConfigured
? t("wizard.finalize.webSearchKeyRef")
: envAvailable
? t("wizard.finalize.webSearchKeyEnv", { env: entry?.envVars.join(" / ") ?? "" })
: undefined;
if (!entry) {
await prompter.note(
[
t("wizard.finalize.webSearchProviderUnavailable", { provider: label }),
t("wizard.finalize.webSearchUnavailableAction"),
` ${formatCliCommand("openclaw configure --section web")}`,
"",
t("wizard.finalize.webDocs"),
].join("\n"),
t("wizard.finalize.webSearchTitle"),
);
} else if (webSearchEnabled !== false && hasKey) {
await prompter.note(
[
t("wizard.finalize.webSearchEnabled"),
"",
t("wizard.finalize.webSearchProvider", { provider: label }),
...(keySource ? [keySource] : []),
t("wizard.finalize.webDocs"),
].join("\n"),
t("wizard.finalize.webSearchTitle"),
);
} else if (!hasKey) {
await prompter.note(
[
t("wizard.finalize.webSearchNoKey", { provider: label }),
t("wizard.finalize.webSearchNeedsKey"),
` ${formatCliCommand("openclaw configure --section web")}`,
"",
t("wizard.finalize.webSearchGetKey", {
url: entry?.signupUrl ?? "https://docs.openclaw.ai/tools/web",
}),
t("wizard.finalize.webDocs"),
].join("\n"),
t("wizard.finalize.webSearchTitle"),
);
} else {
await prompter.note(
[
t("wizard.finalize.webSearchDisabled", { provider: label }),
t("wizard.finalize.webSearchReenable", {
command: formatCliCommand("openclaw configure --section web"),
}),
"",
t("wizard.finalize.webDocs"),
].join("\n"),
t("wizard.finalize.webSearchTitle"),
);
}
} else {
// Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without
// an explicit provider. Runtime auto-detects these, so avoid saying "skipped".
const { hasExistingKey, hasKeyInEnv } = await loadOnboardSearchModule();
const legacyDetected = configuredSearchProviders.find(
(e) => hasExistingKey(nextConfig, e.id) || hasKeyInEnv(e),
);
if (legacyDetected) {
await prompter.note(
[
t("wizard.finalize.webSearchAutoDetected", { provider: legacyDetected.label }),
t("wizard.finalize.webDocs"),
].join("\n"),
t("wizard.finalize.webSearchTitle"),
);
} else if (codexNativeSummary) {
await prompter.note(
[
t("wizard.finalize.managedWebSearchSkipped"),
codexNativeSummary,
t("wizard.finalize.webDocs"),
].join("\n"),
t("wizard.finalize.webSearchTitle"),
);
} else {
await prompter.note(
[
t("wizard.finalize.webSearchSkipped"),
` ${formatCliCommand("openclaw configure --section web")}`,
"",
t("wizard.finalize.webDocs"),
].join("\n"),
t("wizard.finalize.webSearchTitle"),
);
}
}
if (codexNativeSummary) {
await prompter.note(
[
codexNativeSummary,
t("wizard.finalize.codexNativeSearchOnly"),
t("wizard.finalize.webDocs"),
].join("\n"),
t("wizard.finalize.codexNativeSearchTitle"),
);
}
await prompter.note(t("wizard.finalize.whatNow"), t("wizard.finalize.whatNowTitle"));
await prompter.outro(
controlUiOpened
? t("wizard.finalize.outroDashboardOpened")
: seededInBackground
? t("wizard.finalize.outroSeeded")
: t("wizard.finalize.outroDashboardLink"),
);
return { launchedTui };
}