diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 813f1f70d628..dfe42a394954 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -9,7 +9,7 @@ title: "Configure" Interactive prompt for targeted changes to an existing setup: credentials, devices, agent defaults, gateway, channels, plugins, skills, and health checks. -Use `openclaw onboard` for the full guided first-run journey, `openclaw setup` for the baseline config/workspace only, and `openclaw channels add` when you only need channel account setup. +Use `openclaw onboard` or `openclaw setup` for the full guided first-run journey, `openclaw setup --baseline` for the baseline config/workspace only, and `openclaw channels add` when you only need channel account setup. The **Model** section includes a multi-select for the `agents.defaults.models` allowlist (what shows up in `/model` and the model picker). Provider-scoped setup choices merge their selected models into the existing allowlist instead of replacing unrelated providers already in the config. diff --git a/docs/cli/index.md b/docs/cli/index.md index 3572806a87ed..f6587d723227 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -13,8 +13,8 @@ apply across the CLI. Use the setup commands by intent: -- `openclaw setup` creates the baseline config and workspace without walking the full guided onboarding flow. -- `openclaw onboard` is the full guided first-run path for gateway, model auth, workspace, channels, skills, and health. +- `openclaw setup` and `openclaw onboard` run the full guided first-run path for gateway, model auth, workspace, channels, skills, and health. +- `openclaw setup --baseline` creates the baseline config and workspace without walking the guided onboarding flow. - `openclaw configure` changes targeted parts of an existing setup, such as model auth, gateway, channels, plugins, or skills. - `openclaw channels add` configures channel accounts after the baseline exists; run it without flags for guided channel setup or with channel-specific flags for scripts. diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index f0d2ceab041a..91beec3049b0 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -247,7 +247,7 @@ openclaw configure openclaw agents add ``` -Use `openclaw setup` instead when you only need the baseline config/workspace. Use `openclaw configure` later for targeted changes and `openclaw channels add` for channel-only setup. +Use `openclaw setup` as the same guided onboarding entry point. Use `openclaw setup --baseline` when you only need the baseline config/workspace, `openclaw configure` later for targeted changes, and `openclaw channels add` for channel-only setup. `--json` does not imply non-interactive mode. Use `--non-interactive` for scripts. diff --git a/docs/cli/setup.md b/docs/cli/setup.md index 17ebbc61e44a..a79ca00e8f30 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -1,15 +1,15 @@ --- -summary: "CLI reference for `openclaw setup` (initialize config plus workspace, optionally run onboarding)" +summary: "CLI reference for `openclaw setup` (alias for onboarding, with baseline setup available by flag)" read_when: - - You're doing first-run setup without full CLI onboarding + - You're doing first-run setup with the CLI onboarding wizard - You want to set the default workspace path - - You need every flag and how setup decides between baseline and wizard mode + - You need the baseline-only setup flag for scripts title: "Setup" --- # `openclaw setup` -Initialize the baseline config and agent workspace. With any onboarding flag present, also runs the wizard. +Run the full CLI onboarding flow. `openclaw setup` is an alias for `openclaw onboard`; use `--baseline` when you only need to initialize config/workspace folders without the wizard. `openclaw setup` is for mutable config installs. In Nix mode (`OPENCLAW_NIX_MODE=1`) OpenClaw refuses setup writes because the config file is managed by Nix. Use the first-party [nix-openclaw Quick Start](https://github.com/openclaw/nix-openclaw#quick-start) or the equivalent source config for another Nix package. @@ -20,7 +20,8 @@ Initialize the baseline config and agent workspace. With any onboarding flag pre | Flag | Description | | -------------------------- | --------------------------------------------------------------------------------------------------- | | `--workspace ` | Agent workspace directory (default `~/.openclaw/workspace`; stored as `agents.defaults.workspace`). | -| `--wizard` | Run interactive onboarding. | +| `--baseline` | Create baseline config/workspace/session folders without onboarding. | +| `--wizard` | Accepted for compatibility; setup runs onboarding by default. | | `--non-interactive` | Run onboarding without prompts. | | `--accept-risk` | Acknowledge full-system agent access risk; required with `--non-interactive`. | | `--mode ` | Onboarding mode: `local` or `remote`. | @@ -30,26 +31,24 @@ Initialize the baseline config and agent workspace. With any onboarding flag pre | `--remote-url ` | Remote Gateway WebSocket URL. | | `--remote-token ` | Remote Gateway token (optional). | -### Wizard auto-trigger +### Baseline mode -`openclaw setup` runs the wizard when any of these flags are explicitly present, even without `--wizard`: - -`--wizard`, `--non-interactive`, `--accept-risk`, `--mode`, `--import-from`, `--import-source`, `--import-secrets`, `--remote-url`, `--remote-token`. +`openclaw setup --baseline` preserves the older baseline-only behavior: it creates the config, workspace, and session directories, then exits without running onboarding. ## Examples ```bash openclaw setup +openclaw setup --baseline openclaw setup --workspace ~/.openclaw/workspace -openclaw setup --wizard -openclaw setup --wizard --import-from hermes --import-source ~/.hermes +openclaw setup --import-from hermes --import-source ~/.hermes openclaw setup --non-interactive --accept-risk --mode remote --remote-url wss://gateway-host:18789 --remote-token ``` ## Notes -- Plain `openclaw setup` initializes config and workspace without running the full onboarding flow. -- After plain setup, run `openclaw onboard` for the full guided journey, `openclaw configure` for targeted changes, or `openclaw channels add` to add channel accounts. +- Plain `openclaw setup` runs the same guided journey as `openclaw onboard`. +- After baseline setup, run `openclaw setup` or `openclaw onboard` for the full guided journey, `openclaw configure` for targeted changes, or `openclaw channels add` to add channel accounts. - If Hermes state is detected, interactive onboarding can offer migration automatically. Import onboarding requires a fresh setup; use [Migrate](/cli/migrate) for dry-run plans, backups, and overwrite mode outside onboarding. ## Related diff --git a/docs/docs_map.md b/docs/docs_map.md index 76255cff4e87..e1236feac727 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -1887,7 +1887,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H1: openclaw setup - H2: Options - - H3: Wizard auto-trigger + - H3: Baseline mode - H2: Examples - H2: Notes - H2: Related diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 22325cccfcb5..b79d022c06a4 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1260,6 +1260,7 @@ Metadata written by CLI guided setup flows (`onboard`, `configure`, `doctor`): lastRunCommit: "abc1234", lastRunCommand: "configure", lastRunMode: "local", + securityAcknowledgedAt: "2026-01-01T00:00:00.000Z", }, } ``` diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 5adce84f4215..989f0e57088a 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -295,6 +295,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `wizard.lastRunCommit` - `wizard.lastRunCommand` - `wizard.lastRunMode` +- `wizard.securityAcknowledgedAt` `openclaw agents add` writes `agents.list[]` and optional `bindings`. diff --git a/scripts/lib/official-external-provider-catalog.json b/scripts/lib/official-external-provider-catalog.json index d2df733af35d..1dbb912d0652 100644 --- a/scripts/lib/official-external-provider-catalog.json +++ b/scripts/lib/official-external-provider-catalog.json @@ -359,6 +359,9 @@ "id": "codex", "label": "Codex" }, + "contracts": { + "migrationProviders": ["codex"] + }, "providers": [ { "id": "codex", diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 0e2c6869413c..a397eb441fb2 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -10,6 +10,7 @@ import type { GatewayAuthChoice, GatewayBind, NodeManagerChoice, + OnboardOptions, ResetScope, SecretInputMode, TailscaleMode, @@ -90,6 +91,70 @@ function pickOnboardProviderAuthOptionValues( ); } +export function registerOnboardAuthOptions(command: Command): Command { + command + .option("--auth-choice ", `Auth: ${AUTH_CHOICE_HELP}`) + .option( + "--token-provider ", + "Token provider id (non-interactive; used with --auth-choice token)", + ) + .option("--token ", "Token value (non-interactive; used with --auth-choice token)") + .option( + "--token-profile-id ", + "Auth profile id (non-interactive; default: :manual)", + ) + .option("--token-expires-in ", "Optional token expiry duration (e.g. 365d, 12h)") + .option( + "--secret-input-mode ", + "API key persistence mode: plaintext|ref (default: plaintext)", + ) + .option("--cloudflare-ai-gateway-account-id ", "Cloudflare Account ID") + .option("--cloudflare-ai-gateway-gateway-id ", "Cloudflare AI Gateway ID"); + + for (const providerFlag of ONBOARD_AUTH_FLAGS) { + command.option(providerFlag.cliOption, providerFlag.description); + } + + return command + .option("--custom-base-url ", "Custom provider base URL") + .option("--custom-api-key ", "Custom provider API key (optional)") + .option("--custom-model-id ", "Custom provider model ID") + .option("--custom-provider-id ", "Custom provider ID (optional; auto-derived by default)") + .option( + "--custom-compatibility ", + "Custom provider API compatibility: openai|openai-responses|anthropic (default: openai)", + ) + .option("--custom-image-input", "Mark the custom provider model as image-capable") + .option("--custom-text-input", "Mark the custom provider model as text-only"); +} + +export function pickOnboardAuthOptionValues( + opts: Record, +): Partial { + const customTextInput = opts.customTextInput === true; + return { + authChoice: opts.authChoice as AuthChoice | undefined, + tokenProvider: opts.tokenProvider as string | undefined, + token: opts.token as string | undefined, + tokenProfileId: opts.tokenProfileId as string | undefined, + tokenExpiresIn: opts.tokenExpiresIn as string | undefined, + secretInputMode: opts.secretInputMode as SecretInputMode | undefined, + ...pickOnboardProviderAuthOptionValues(opts), + cloudflareAiGatewayAccountId: opts.cloudflareAiGatewayAccountId as string | undefined, + cloudflareAiGatewayGatewayId: opts.cloudflareAiGatewayGatewayId as string | undefined, + customBaseUrl: opts.customBaseUrl as string | undefined, + customApiKey: opts.customApiKey as string | undefined, + customModelId: opts.customModelId as string | undefined, + customProviderId: opts.customProviderId as string | undefined, + customCompatibility: opts.customCompatibility as + | "openai" + | "openai-responses" + | "anthropic" + | undefined, + customImageInput: customTextInput ? false : opts.customImageInput === true ? true : undefined, + }; +} + export function registerOnboardCommand(program: Command): void { const command = program .command("onboard") @@ -113,40 +178,11 @@ export function registerOnboardCommand(program: Command): void { false, ) .option("--flow ", "Onboard flow: quickstart|advanced|manual|import") - .option("--mode ", "Onboard mode: local|remote") - .option("--auth-choice ", `Auth: ${AUTH_CHOICE_HELP}`) - .option( - "--token-provider ", - "Token provider id (non-interactive; used with --auth-choice token)", - ) - .option("--token ", "Token value (non-interactive; used with --auth-choice token)") - .option( - "--token-profile-id ", - "Auth profile id (non-interactive; default: :manual)", - ) - .option("--token-expires-in ", "Optional token expiry duration (e.g. 365d, 12h)") - .option( - "--secret-input-mode ", - "API key persistence mode: plaintext|ref (default: plaintext)", - ) - .option("--cloudflare-ai-gateway-account-id ", "Cloudflare Account ID") - .option("--cloudflare-ai-gateway-gateway-id ", "Cloudflare AI Gateway ID"); + .option("--mode ", "Onboard mode: local|remote"); - for (const providerFlag of ONBOARD_AUTH_FLAGS) { - command.option(providerFlag.cliOption, providerFlag.description); - } + registerOnboardAuthOptions(command); command - .option("--custom-base-url ", "Custom provider base URL") - .option("--custom-api-key ", "Custom provider API key (optional)") - .option("--custom-model-id ", "Custom provider model ID") - .option("--custom-provider-id ", "Custom provider ID (optional; auto-derived by default)") - .option( - "--custom-compatibility ", - "Custom provider API compatibility: openai|openai-responses|anthropic (default: openai)", - ) - .option("--custom-image-input", "Mark the custom provider model as image-capable") - .option("--custom-text-input", "Mark the custom provider model as text-only") .option("--gateway-port ", "Gateway port") .option("--gateway-bind ", "Gateway bind: loopback|tailnet|lan|auto|custom") .option("--gateway-auth ", "Gateway auth: token|password") @@ -195,9 +231,6 @@ export function registerOnboardCommand(program: Command): void { installDaemon: Boolean(opts.installDaemon), }); const gatewayPort = parsePort(opts.gatewayPort); - const providerAuthOptionValues = pickOnboardProviderAuthOptionValues( - opts as Record, - ); const { setupWizardCommand } = await import("../../commands/onboard.js"); await setupWizardCommand( { @@ -206,30 +239,7 @@ export function registerOnboardCommand(program: Command): void { acceptRisk: Boolean(opts.acceptRisk), flow: opts.flow as "quickstart" | "advanced" | "manual" | "import" | undefined, mode: opts.mode as "local" | "remote" | undefined, - authChoice: opts.authChoice as AuthChoice | undefined, - tokenProvider: opts.tokenProvider as string | undefined, - token: opts.token as string | undefined, - tokenProfileId: opts.tokenProfileId as string | undefined, - tokenExpiresIn: opts.tokenExpiresIn as string | undefined, - secretInputMode: opts.secretInputMode as SecretInputMode | undefined, - ...providerAuthOptionValues, - cloudflareAiGatewayAccountId: opts.cloudflareAiGatewayAccountId as string | undefined, - cloudflareAiGatewayGatewayId: opts.cloudflareAiGatewayGatewayId as string | undefined, - customBaseUrl: opts.customBaseUrl as string | undefined, - customApiKey: opts.customApiKey as string | undefined, - customModelId: opts.customModelId as string | undefined, - customProviderId: opts.customProviderId as string | undefined, - customCompatibility: opts.customCompatibility as - | "openai" - | "openai-responses" - | "anthropic" - | undefined, - customImageInput: - opts.customTextInput === true - ? false - : opts.customImageInput === true - ? true - : undefined, + ...pickOnboardAuthOptionValues(opts as Record), gatewayPort: gatewayPort ?? undefined, gatewayBind: opts.gatewayBind as GatewayBind | undefined, gatewayAuth: opts.gatewayAuth as GatewayAuthChoice | undefined, diff --git a/src/cli/program/register.setup.test.ts b/src/cli/program/register.setup.test.ts index 32f4a2a4aaea..5b8f186f9c76 100644 --- a/src/cli/program/register.setup.test.ts +++ b/src/cli/program/register.setup.test.ts @@ -52,9 +52,17 @@ describe("registerSetupCommand", () => { setupWizardCommandMock.mockResolvedValue(undefined); }); - it("runs setup command by default", async () => { + it("runs setup wizard command by default", async () => { await runCli(["setup", "--workspace", "/tmp/ws"]); + expect(setupWizardCommandMock).toHaveBeenCalledWith(lastWizardOptions(), runtime); + expect(lastWizardOptions()?.workspace).toBe("/tmp/ws"); + expect(setupCommandMock).not.toHaveBeenCalled(); + }); + + it("runs baseline setup command when --baseline is set", async () => { + await runCli(["setup", "--baseline", "--workspace", "/tmp/ws"]); + expect(setupCommandMock).toHaveBeenCalledWith(lastSetupOptions(), runtime); expect(lastSetupOptions()?.workspace).toBe("/tmp/ws"); expect(setupWizardCommandMock).not.toHaveBeenCalled(); @@ -79,6 +87,106 @@ describe("registerSetupCommand", () => { expect(setupCommandMock).not.toHaveBeenCalled(); }); + it("forwards scripted onboarding controls", async () => { + await runCli([ + "setup", + "--non-interactive", + "--accept-risk", + "--flow", + "advanced", + "--gateway-port", + "18789", + "--install-daemon", + "--skip-daemon", + "--skip-health", + "--skip-ui", + "--skip-channels", + "--skip-search", + "--skip-skills", + "--skip-bootstrap", + "--node-manager", + "pnpm", + "--json", + ]); + + expect(setupWizardCommandMock).toHaveBeenCalledWith(lastWizardOptions(), runtime); + expect(lastWizardOptions()).toMatchObject({ + nonInteractive: true, + acceptRisk: true, + flow: "advanced", + gatewayPort: 18789, + installDaemon: false, + skipHealth: true, + skipUi: true, + skipChannels: true, + skipSearch: true, + skipSkills: true, + skipBootstrap: true, + nodeManager: "pnpm", + json: true, + }); + expect(setupCommandMock).not.toHaveBeenCalled(); + }); + + it("forwards onboard auth flags through the setup alias", async () => { + await runCli([ + "setup", + "--non-interactive", + "--accept-risk", + "--auth-choice", + "token", + "--token-provider", + "openai", + "--token", + "test-token", + "--token-profile-id", + "openai:manual", + "--token-expires-in", + "1d", + "--secret-input-mode", + "ref", + "--openai-api-key", + "test-openai-api-key", + "--cloudflare-ai-gateway-account-id", + "account-id", + "--cloudflare-ai-gateway-gateway-id", + "gateway-id", + "--custom-base-url", + "https://example.test/v1", + "--custom-api-key", + "test-custom-api-key", + "--custom-model-id", + "custom-model", + "--custom-provider-id", + "custom-provider", + "--custom-compatibility", + "anthropic", + "--custom-text-input", + ]); + + expect(setupWizardCommandMock).toHaveBeenCalledWith(lastWizardOptions(), runtime); + expect(lastWizardOptions()).toMatchObject({ + nonInteractive: true, + acceptRisk: true, + authChoice: "token", + tokenProvider: "openai", + token: "test-token", + tokenProfileId: "openai:manual", + tokenExpiresIn: "1d", + secretInputMode: "ref", + openaiApiKey: "test-openai-api-key", + cloudflareAiGatewayAccountId: "account-id", + cloudflareAiGatewayGatewayId: "gateway-id", + customBaseUrl: "https://example.test/v1", + customApiKey: "test-custom-api-key", + customModelId: "custom-model", + customProviderId: "custom-provider", + customCompatibility: "anthropic", + customImageInput: false, + }); + expect(setupCommandMock).not.toHaveBeenCalled(); + }); + it("runs setup wizard command for migration import flags", async () => { await runCli([ "setup", @@ -97,7 +205,7 @@ describe("registerSetupCommand", () => { }); it("reports setup errors through runtime", async () => { - setupCommandMock.mockRejectedValueOnce(new Error("setup failed")); + setupWizardCommandMock.mockRejectedValueOnce(new Error("setup failed")); await runCli(["setup"]); diff --git a/src/cli/program/register.setup.ts b/src/cli/program/register.setup.ts index e2ffb2bcdbf3..7c25c722605e 100644 --- a/src/cli/program/register.setup.ts +++ b/src/cli/program/register.setup.ts @@ -2,21 +2,50 @@ import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; +import type { GatewayDaemonRuntime } from "../../commands/daemon-runtime.js"; +import type { + GatewayAuthChoice, + GatewayBind, + NodeManagerChoice, + ResetScope, + TailscaleMode, +} from "../../commands/onboard-types.js"; import { runCommandWithRuntime } from "../cli-utils.js"; -import { hasExplicitOptions } from "../command-options.js"; +import { parsePort } from "../shared/parse-port.js"; +import { pickOnboardAuthOptionValues, registerOnboardAuthOptions } from "./register.onboard.js"; -/** Register the `setup` command and route wizard-style invocations to onboarding. */ +function resolveInstallDaemonFlag( + command: unknown, + opts: { installDaemon?: boolean }, +): boolean | undefined { + if (!command || typeof command !== "object") { + return undefined; + } + const getOptionValueSource = + "getOptionValueSource" in command ? command.getOptionValueSource : undefined; + if (typeof getOptionValueSource !== "function") { + return undefined; + } + + if (getOptionValueSource.call(command, "skipDaemon") === "cli") { + return false; + } + if (getOptionValueSource.call(command, "installDaemon") === "cli") { + return Boolean(opts.installDaemon); + } + return undefined; +} + +/** Register the `setup` command as an onboarding alias. */ export function registerSetupCommand(program: Command): void { - program + const command = program .command("setup") - .description("Create baseline config/workspace files; use --wizard for full onboarding") + .description("Alias for openclaw onboard") .addHelpText( "after", () => `\n${theme.heading("Examples:")}\n` + ` ${theme.command("openclaw setup")}\n` + - ` ${theme.muted("Create config, workspace, and session folders.")}\n` + - ` ${theme.command("openclaw setup --wizard")}\n` + ` ${theme.muted("Run full onboarding for auth, models, Gateway, and channels.")}\n\n` + `${theme.muted("Docs:")} ${formatDocsLink("/cli/setup", "docs.openclaw.ai/cli/setup")}\n`, ) @@ -25,53 +54,109 @@ export function registerSetupCommand(program: Command): void { "Agent workspace directory (default: ~/.openclaw/workspace; stored as agents.defaults.workspace)", ) .option("--wizard", "Run interactive onboarding", false) + .option( + "--baseline", + "Create baseline config/workspace/session folders without onboarding", + false, + ) + .option( + "--reset", + "Reset config + credentials + sessions before running onboarding (workspace only with --reset-scope full)", + ) + .option("--reset-scope ", "Reset scope: config|config+creds+sessions|full") .option("--non-interactive", "Run onboarding without prompts", false) .option( "--accept-risk", "Acknowledge that agents are powerful and full system access is risky (required for --non-interactive)", false, ) - .option("--mode ", "Onboard mode: local|remote") + .option("--flow ", "Onboard flow: quickstart|advanced|manual|import") + .option("--mode ", "Onboard mode: local|remote"); + + registerOnboardAuthOptions(command); + + command + .option("--gateway-port ", "Gateway port") + .option("--gateway-bind ", "Gateway bind: loopback|tailnet|lan|auto|custom") + .option("--gateway-auth ", "Gateway auth: token|password") + .option("--gateway-token ", "Gateway token (token auth)") + .option( + "--gateway-token-ref-env ", + "Gateway token SecretRef env var name (token auth; e.g. OPENCLAW_GATEWAY_TOKEN)", + ) + .option("--gateway-password ", "Gateway password (password auth)") + .option("--tailscale ", "Tailscale: off|serve|funnel") + .option("--tailscale-reset-on-exit", "Reset tailscale serve/funnel on exit") + .option("--install-daemon", "Install gateway service") + .option("--no-install-daemon", "Skip gateway service install") + .option("--skip-daemon", "Skip gateway service install") + .option("--daemon-runtime ", "Daemon runtime: node|bun") + .option("--skip-channels", "Skip channel setup") + .option("--skip-skills", "Skip skills setup") + .option("--skip-bootstrap", "Skip creating default agent workspace files") + .option("--skip-search", "Skip search provider setup") + .option("--skip-health", "Skip health check") + .option("--skip-ui", "Skip Control UI/TUI launch") + .option("--suppress-gateway-token-output", "Suppress token-bearing Gateway/UI output") + .option("--skip-hooks", "Accepted for onboard compatibility; hooks setup is skipped") + .option("--node-manager ", "Node manager for skills: npm|pnpm|bun") .option("--import-from ", "Migration provider to run during onboarding") .option("--import-source ", "Source agent home for --import-from") .option("--import-secrets", "Import supported secrets during onboarding migration", false) .option("--remote-url ", "Remote Gateway WebSocket URL") .option("--remote-token ", "Remote Gateway token (optional)") - .action(async (opts, command) => { + .option("--json", "Output JSON summary", false) + .action(async (opts, commandRuntime) => { const { defaultRuntime } = await import("../../runtime.js"); await runCommandWithRuntime(defaultRuntime, async () => { - const hasWizardFlags = hasExplicitOptions(command, [ - "wizard", - "nonInteractive", - "acceptRisk", - "mode", - "importFrom", - "importSource", - "importSecrets", - "remoteUrl", - "remoteToken", - ]); - // Any onboarding-only flag means the user intended the wizard path even without --wizard. - if (opts.wizard || hasWizardFlags) { - const { setupWizardCommand } = await import("../../commands/onboard.js"); - await setupWizardCommand( - { - workspace: opts.workspace as string | undefined, - nonInteractive: Boolean(opts.nonInteractive), - acceptRisk: Boolean(opts.acceptRisk), - mode: opts.mode as "local" | "remote" | undefined, - importFrom: opts.importFrom as string | undefined, - importSource: opts.importSource as string | undefined, - importSecrets: Boolean(opts.importSecrets), - remoteUrl: opts.remoteUrl as string | undefined, - remoteToken: opts.remoteToken as string | undefined, - }, - defaultRuntime, - ); + if (opts.baseline) { + const { setupCommand } = await import("../../commands/setup.js"); + await setupCommand({ workspace: opts.workspace as string | undefined }, defaultRuntime); return; } - const { setupCommand } = await import("../../commands/setup.js"); - await setupCommand({ workspace: opts.workspace as string | undefined }, defaultRuntime); + const installDaemon = resolveInstallDaemonFlag(commandRuntime, { + installDaemon: Boolean(opts.installDaemon), + }); + const gatewayPort = parsePort(opts.gatewayPort); + const { setupWizardCommand } = await import("../../commands/onboard.js"); + await setupWizardCommand( + { + workspace: opts.workspace as string | undefined, + nonInteractive: Boolean(opts.nonInteractive), + acceptRisk: Boolean(opts.acceptRisk), + flow: opts.flow as "quickstart" | "advanced" | "manual" | "import" | undefined, + mode: opts.mode as "local" | "remote" | undefined, + ...pickOnboardAuthOptionValues(opts as Record), + reset: Boolean(opts.reset), + resetScope: opts.resetScope as ResetScope | undefined, + gatewayPort: gatewayPort ?? undefined, + gatewayBind: opts.gatewayBind as GatewayBind | undefined, + gatewayAuth: opts.gatewayAuth as GatewayAuthChoice | undefined, + gatewayToken: opts.gatewayToken as string | undefined, + gatewayTokenRefEnv: opts.gatewayTokenRefEnv as string | undefined, + gatewayPassword: opts.gatewayPassword as string | undefined, + tailscale: opts.tailscale as TailscaleMode | undefined, + tailscaleResetOnExit: Boolean(opts.tailscaleResetOnExit), + installDaemon, + daemonRuntime: opts.daemonRuntime as GatewayDaemonRuntime | undefined, + skipChannels: Boolean(opts.skipChannels), + skipSkills: Boolean(opts.skipSkills), + skipBootstrap: Boolean(opts.skipBootstrap), + skipSearch: Boolean(opts.skipSearch), + skipHealth: Boolean(opts.skipHealth), + skipUi: Boolean(opts.skipUi), + suppressGatewayTokenOutput: Boolean(opts.suppressGatewayTokenOutput), + skipHooks: Boolean(opts.skipHooks), + nodeManager: opts.nodeManager as NodeManagerChoice | undefined, + importFrom: opts.importFrom as string | undefined, + importSource: opts.importSource as string | undefined, + importSecrets: Boolean(opts.importSecrets), + remoteUrl: opts.remoteUrl as string | undefined, + remoteToken: opts.remoteToken as string | undefined, + json: Boolean(opts.json), + }, + defaultRuntime, + ); }); }); } diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index e8eb5adb0542..fdf484229c01 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -77,6 +77,18 @@ const setupWizardCommandMock = vi.hoisted(() => vi.fn(async () => {})); const runCrestodianMock = vi.hoisted(() => vi.fn<(options?: unknown) => Promise>(async () => {}), ); +const launchTuiCliMock = vi.hoisted(() => + vi.fn<(opts: unknown, launchOptions?: unknown) => Promise>(async () => {}), +); +const probeGatewayReachableMock = vi.hoisted(() => + vi.fn<() => Promise<{ ok: boolean; detail?: string }>>(async () => ({ ok: true })), +); +const resolveControlUiLinksMock = vi.hoisted(() => + vi.fn(() => ({ + httpUrl: "http://127.0.0.1:18789/", + wsUrl: "ws://127.0.0.1:18789", + })), +); const commanderParseAsyncMock = vi.hoisted(() => vi.fn(async () => {})); type GatewayRunCommandHooks = { beforeRun?: (opts: { reset?: boolean }) => Promise; @@ -200,6 +212,10 @@ vi.mock("../config/paths.js", async (importOriginal) => ({ pinRuntimePaths: pinRuntimePathsMock, })); +vi.mock("../gateway/control-ui-links.js", () => ({ + resolveControlUiLinks: resolveControlUiLinksMock, +})); + vi.mock("../utils.js", async (importOriginal) => ({ ...(await importOriginal()), pinConfigDir: pinConfigDirMock, @@ -305,6 +321,14 @@ vi.mock("../commands/onboard.js", () => ({ setupWizardCommand: setupWizardCommandMock, })); +vi.mock("../commands/onboard-helpers.js", () => ({ + probeGatewayReachable: probeGatewayReachableMock, +})); + +vi.mock("../tui/tui-launch.js", () => ({ + launchTuiCli: launchTuiCliMock, +})); + vi.mock("../crestodian/crestodian.js", () => ({ runCrestodian: runCrestodianMock, })); @@ -366,6 +390,11 @@ describe("runCli exit behavior", () => { valid: true, sourceConfig: { gateway: { mode: "local" } }, }); + probeGatewayReachableMock.mockResolvedValue({ ok: true }); + resolveControlUiLinksMock.mockReturnValue({ + httpUrl: "http://127.0.0.1:18789/", + wsUrl: "ws://127.0.0.1:18789", + }); hasMemoryRuntimeMock.mockReturnValue(false); listRegisteredAgentHarnessesMock.mockReturnValue([]); outputPrecomputedBrowserHelpTextMock.mockReturnValue(false); @@ -2589,20 +2618,253 @@ describe("runCli exit behavior", () => { } }); - it("keeps bare root invocations on Crestodian when config already exists", async () => { - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); + it("starts the gateway-backed TUI for bare root invocations when config already exists", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: true, + sourceConfig: { + gateway: { + mode: "local", + auth: { + mode: "password", + password: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_PASSWORD", + }, + }, + }, + }, + }); + + await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "gateway-ref-password" }, async () => { + await withInteractiveTty(async () => { + await runCli(["node", "openclaw"]); + }); }); expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1); expect(setupWizardCommandMock).not.toHaveBeenCalled(); + expect(resolveControlUiLinksMock).toHaveBeenCalledWith({ + port: 18789, + bind: "loopback", + basePath: undefined, + tlsEnabled: false, + }); + expect(probeGatewayReachableMock).toHaveBeenCalledWith({ + url: "ws://127.0.0.1:18789", + password: "gateway-ref-password", + }); + expect(launchTuiCliMock).toHaveBeenCalledWith( + { deliver: false }, + { gatewayUrl: "ws://127.0.0.1:18789", authSource: "config" }, + ); + expect(runCrestodianMock).not.toHaveBeenCalled(); + }); + + it("uses gateway env credentials for bare root gateway preflight", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + exists: true, + valid: true, + sourceConfig: { + gateway: { + mode: "local", + auth: { mode: "token" }, + }, + }, + }); + + await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => { + await withInteractiveTty(async () => { + await runCli(["node", "openclaw"]); + }); + }); + + expect(probeGatewayReachableMock).toHaveBeenCalledWith({ + url: "ws://127.0.0.1:18789", + token: "env-token", + }); + expect(launchTuiCliMock).toHaveBeenCalledWith( + { deliver: false }, + { gatewayUrl: "ws://127.0.0.1:18789" }, + ); + }); + + it("probes local gateways over loopback even when the gateway advertises a LAN bind", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + exists: true, + valid: true, + sourceConfig: { + gateway: { + mode: "local", + bind: "lan", + auth: { + mode: "token", + token: "local-token", + }, + }, + }, + }); + + await withInteractiveTty(async () => { + await runCli(["node", "openclaw"]); + }); + + expect(resolveControlUiLinksMock).toHaveBeenCalledWith({ + port: 18789, + bind: "loopback", + basePath: undefined, + tlsEnabled: false, + }); + expect(probeGatewayReachableMock).toHaveBeenCalledWith({ + url: "ws://127.0.0.1:18789", + token: "local-token", + }); + expect(launchTuiCliMock).toHaveBeenCalledWith( + { deliver: false }, + { gatewayUrl: "ws://127.0.0.1:18789", authSource: "config" }, + ); + }); + + it("falls back to the configured local tailnet gateway URL when loopback is unavailable", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + exists: true, + valid: true, + sourceConfig: { + gateway: { + mode: "local", + bind: "tailnet", + auth: { + mode: "token", + token: "local-token", + }, + }, + }, + }); + resolveControlUiLinksMock.mockImplementation(({ bind }: { bind?: string } = {}) => + bind === "tailnet" + ? { + httpUrl: "http://100.64.0.10:18789/", + wsUrl: "ws://100.64.0.10:18789", + } + : { + httpUrl: "http://127.0.0.1:18789/", + wsUrl: "ws://127.0.0.1:18789", + }, + ); + probeGatewayReachableMock + .mockResolvedValueOnce({ ok: false, detail: "loopback offline" }) + .mockResolvedValueOnce({ ok: true }); + + await withInteractiveTty(async () => { + await runCli(["node", "openclaw"]); + }); + + expect(probeGatewayReachableMock).toHaveBeenNthCalledWith(1, { + url: "ws://127.0.0.1:18789", + token: "local-token", + }); + expect(probeGatewayReachableMock).toHaveBeenNthCalledWith(2, { + url: "ws://100.64.0.10:18789", + token: "local-token", + }); + expect(launchTuiCliMock).toHaveBeenCalledWith( + { deliver: false }, + { gatewayUrl: "ws://100.64.0.10:18789", authSource: "config" }, + ); + }); + + it("starts the local TUI for bare root invocations when the gateway is unavailable", async () => { + probeGatewayReachableMock.mockResolvedValueOnce({ ok: false, detail: "offline" }); + + await withInteractiveTty(async () => { + await runCli(["node", "openclaw"]); + }); + + expect(launchTuiCliMock).toHaveBeenCalledWith({ deliver: false, local: true }, {}); + expect(runCrestodianMock).not.toHaveBeenCalled(); + }); + + it("does not probe unsafe remote gateway URLs with configured credentials", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + exists: true, + valid: true, + sourceConfig: { + gateway: { + mode: "remote", + remote: { + url: "ws://192.168.1.10:18789", + token: "remote-token", + }, + }, + }, + }); + + await withEnvAsync({ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: undefined }, async () => { + await withInteractiveTty(async () => { + await runCli(["node", "openclaw"]); + }); + }); + + expect(probeGatewayReachableMock).not.toHaveBeenCalled(); + expect(launchTuiCliMock).toHaveBeenCalledWith({ deliver: false, local: true }, {}); + expect(runCrestodianMock).not.toHaveBeenCalled(); + }); + + it("rejects configured bare root TUI startup without an interactive TTY", async () => { + const previousExitCode = process.exitCode; + const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + process.exitCode = undefined; + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false }); + + try { + await runCli(["node", "openclaw"]); + + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + "OpenClaw TUI needs an interactive TTY. Use `openclaw agent --local ...` for automation.", + ); + expect(launchTuiCliMock).not.toHaveBeenCalled(); + expect(runCrestodianMock).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + process.exitCode = previousExitCode; + if (stdinDescriptor) { + Object.defineProperty(process.stdin, "isTTY", stdinDescriptor); + } else { + Reflect.deleteProperty(process.stdin, "isTTY"); + } + if (stdoutDescriptor) { + Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor); + } else { + Reflect.deleteProperty(process.stdout, "isTTY"); + } + } + }); + + it("keeps invalid configured bare root invocations on Crestodian", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + exists: true, + valid: false, + sourceConfig: { gateway: { mode: "local" } }, + }); + + await withInteractiveTty(async () => { + await runCli(["node", "openclaw"]); + }); + + expect(setupWizardCommandMock).not.toHaveBeenCalled(); + expect(launchTuiCliMock).not.toHaveBeenCalled(); expect(runCrestodianMock).toHaveBeenCalledOnce(); const crestodianOptions = requireRunCrestodianOptions(); expect(crestodianOptions).toEqual({ onReady: crestodianOptions.onReady }); expect(crestodianOptions.onReady).toBeTypeOf("function"); }); - it("bootstraps env proxy before bare Crestodian startup", async () => { + it("bootstraps env proxy before bare TUI startup", async () => { hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true); const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); const stdoutTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); @@ -2625,12 +2887,12 @@ describe("runCli exit behavior", () => { } expect(ensureGlobalUndiciEnvProxyDispatcherMock).toHaveBeenCalledTimes(1); - expect(runCrestodianMock).toHaveBeenCalledOnce(); - const crestodianOptions = requireRunCrestodianOptions(); - expect(crestodianOptions).toEqual({ onReady: crestodianOptions.onReady }); - expect(crestodianOptions.onReady).toBeTypeOf("function"); + expect(launchTuiCliMock).toHaveBeenCalledOnce(); expect(ensureGlobalUndiciEnvProxyDispatcherMock.mock.invocationCallOrder[0]).toBeLessThan( - runCrestodianMock.mock.invocationCallOrder[0], + probeGatewayReachableMock.mock.invocationCallOrder[0], + ); + expect(ensureGlobalUndiciEnvProxyDispatcherMock.mock.invocationCallOrder[0]).toBeLessThan( + launchTuiCliMock.mock.invocationCallOrder[0], ); }); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index bf3fe67bcb44..d598e48836f0 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -6,6 +6,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import type { Command as CommanderCommand, Option as CommanderOption } from "commander"; import { resolveStateDir } from "../config/paths.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js"; +import { isLoopbackAddress, isSecureWebSocketUrl } from "../gateway/net.js"; import { FLAG_TERMINATOR, isValueToken } from "../infra/cli-root-options.js"; import { isTruthyEnvValue, normalizeEnv } from "../infra/env.js"; import type { ProxyHandle } from "../infra/net/proxy/proxy-lifecycle.js"; @@ -290,6 +291,232 @@ export async function shouldStartOnboardingForFreshInstall(argv: string[]): Prom return isUnconfiguredConfigSnapshot(snapshot); } +type BareRootLaunchTarget = + | { kind: "onboarding" } + | { kind: "tui"; local: boolean; gatewayUrl?: string; authSource?: "config" } + | { kind: "crestodian" }; + +async function resolveBareRootLaunchTarget(argv: string[]): Promise { + if (!shouldStartCrestodianForBareRoot(argv)) { + return null; + } + const { readConfigFileSnapshot } = await import("../config/config.js"); + const snapshot = await readConfigFileSnapshot(); + if (isUnconfiguredConfigSnapshot(snapshot)) { + return { kind: "onboarding" }; + } + if (!snapshot.valid) { + return { kind: "crestodian" }; + } + return resolveConfiguredTuiLaunchTarget(snapshot.config ?? snapshot.sourceConfig); +} + +async function resolveConfiguredTuiLaunchTarget( + config: OpenClawConfig, +): Promise { + const gateway = await resolveReachableGateway(config); + if (gateway) { + const target: BareRootLaunchTarget = { kind: "tui", local: false, gatewayUrl: gateway.url }; + if (gateway.authSource) { + target.authSource = gateway.authSource; + } + return target; + } + return { kind: "tui", local: true }; +} + +type GatewayProbeTarget = { + url: string; + auth: "local" | "remote"; + scope: "local-loopback" | "local-configured" | "remote"; +}; + +type ReachableGateway = { + url: string; + authSource?: "config"; +}; + +type GatewayProbeAuth = { + token?: string; + password?: string; + authSource?: "config"; +}; + +async function resolveReachableGateway(config: OpenClawConfig): Promise { + const targets = await resolveGatewayProbeTargets(config); + if (targets.length === 0) { + return null; + } + const usesRemoteAuth = targets.some((target) => target.auth === "remote"); + const auth = await resolveGatewayProbeAuth(config, usesRemoteAuth ? "remote" : "local"); + const { probeGatewayReachable } = await import("../commands/onboard-helpers.js"); + for (const target of targets) { + if (!isSafeGatewayProbeTarget(target)) { + continue; + } + const probeOptions: { url: string; token?: string; password?: string } = { url: target.url }; + if (auth.token) { + probeOptions.token = auth.token; + } + if (auth.password) { + probeOptions.password = auth.password; + } + const probe = await probeGatewayReachable(probeOptions); + if (probe.ok) { + const reachable: ReachableGateway = { url: target.url }; + if (auth.authSource) { + reachable.authSource = auth.authSource; + } + return reachable; + } + } + return null; +} + +async function resolveGatewayProbeAuth( + config: OpenClawConfig, + auth: "local" | "remote", +): Promise { + const gateway = config.gateway; + const remoteAuth = auth === "remote"; + const [configToken, configPassword] = await Promise.all([ + resolveGatewayProbeSecret({ + config, + value: remoteAuth ? gateway?.remote?.token : gateway?.auth?.token, + path: remoteAuth ? "gateway.remote.token" : "gateway.auth.token", + }), + resolveGatewayProbeSecret({ + config, + value: remoteAuth ? gateway?.remote?.password : gateway?.auth?.password, + path: remoteAuth ? "gateway.remote.password" : "gateway.auth.password", + }), + ]); + const resolved: GatewayProbeAuth = {}; + const token = configToken ?? normalizeOptionalString(process.env.OPENCLAW_GATEWAY_TOKEN); + const password = configPassword ?? normalizeOptionalString(process.env.OPENCLAW_GATEWAY_PASSWORD); + if (token) { + resolved.token = token; + } + if (password) { + resolved.password = password; + } + if (configToken || configPassword) { + resolved.authSource = "config"; + } + return resolved; +} + +async function resolveGatewayProbeTargets(config: OpenClawConfig): Promise { + const remoteUrl = normalizeOptionalString(config.gateway?.remote?.url); + if (normalizeOptionalString(config.gateway?.mode) === "remote" && remoteUrl) { + const url = await resolveValidatedRemoteGatewayUrl(config); + return url ? [{ url, auth: "remote", scope: "remote" }] : []; + } + return (await resolveLocalGatewayWebSocketUrls(config)).map((url, index) => ({ + url, + auth: "local", + scope: index === 0 ? "local-loopback" : "local-configured", + })); +} + +function isSafeGatewayProbeTarget(target: GatewayProbeTarget): boolean { + if (target.scope === "remote") { + return isSafeRemoteGatewayProbeUrl(target.url); + } + return isSecureWebSocketUrl(target.url, { + allowPrivateWs: process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1", + }); +} + +function isSafeRemoteGatewayProbeUrl(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + const protocol = + parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : parsed.protocol; + if (protocol === "wss:") { + return true; + } + if (protocol !== "ws:") { + return false; + } + if (isLoopbackGatewayHost(parsed.hostname)) { + return true; + } + return ( + process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1" && + isSecureWebSocketUrl(url, { allowPrivateWs: true }) + ); +} + +function isLoopbackGatewayHost(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/\.+$/, ""); + if (normalized === "localhost") { + return true; + } + const hostForIpCheck = + normalized.startsWith("[") && normalized.endsWith("]") ? normalized.slice(1, -1) : normalized; + return isLoopbackAddress(hostForIpCheck); +} + +async function resolveValidatedRemoteGatewayUrl(config: OpenClawConfig): Promise { + try { + const { buildGatewayConnectionDetailsWithResolvers } = + await import("../gateway/connection-details.js"); + return buildGatewayConnectionDetailsWithResolvers({ + config, + ignoreEnvUrlOverride: true, + }).url; + } catch { + return null; + } +} + +async function resolveGatewayProbeSecret(params: { + config: OpenClawConfig; + value: unknown; + path: string; +}): Promise { + try { + const { resolveSetupSecretInputString } = await import("../wizard/setup.secret-input.js"); + return await resolveSetupSecretInputString(params); + } catch { + return undefined; + } +} + +async function resolveLocalGatewayWebSocketUrls(config: OpenClawConfig): Promise { + const [{ resolveGatewayPort }, { resolveControlUiLinks }] = await Promise.all([ + import("../config/paths.js"), + import("../gateway/control-ui-links.js"), + ]); + const gateway = config.gateway; + const baseParams = { + port: resolveGatewayPort(config), + basePath: gateway?.controlUi?.basePath, + tlsEnabled: gateway?.tls?.enabled === true, + }; + const loopbackLinks = resolveControlUiLinks({ + ...baseParams, + bind: "loopback", + }); + const bind = gateway?.bind; + if (bind !== "tailnet" && bind !== "custom") { + return [loopbackLinks.wsUrl]; + } + const configuredLinks = resolveControlUiLinks({ + ...baseParams, + bind, + customBindHost: gateway?.customBindHost, + }); + return configuredLinks.wsUrl === loopbackLinks.wsUrl + ? [loopbackLinks.wsUrl] + : [loopbackLinks.wsUrl, configuredLinks.wsUrl]; +} + function pauseNonTtyStdinForCliExit(): void { const stdin = process.stdin; if (stdin.isTTY) { @@ -836,14 +1063,17 @@ export async function runCli(argv: string[] = process.argv) { } } - const shouldRunBareRootCrestodian = shouldStartCrestodianForBareRoot(normalizedArgv); + const shouldRunBareRootCommand = shouldStartCrestodianForBareRoot(normalizedArgv); const shouldRunModernOnboardCrestodian = shouldStartCrestodianForModernOnboard(normalizedArgv); - if (shouldRunBareRootCrestodian || shouldRunModernOnboardCrestodian) { + if (shouldRunBareRootCommand || shouldRunModernOnboardCrestodian) { await ensureCliEnvProxyDispatcher(); } + const bareRootLaunchTarget = shouldRunBareRootCommand + ? await resolveBareRootLaunchTarget(normalizedArgv) + : null; - if (shouldRunBareRootCrestodian) { - if (await shouldStartOnboardingForFreshInstall(normalizedArgv)) { + if (bareRootLaunchTarget) { + if (bareRootLaunchTarget.kind === "onboarding") { if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error( "Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.", @@ -855,6 +1085,28 @@ export async function runCli(argv: string[] = process.argv) { await setupWizardCommand({}); return; } + if (bareRootLaunchTarget.kind === "tui") { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.error( + "OpenClaw TUI needs an interactive TTY. Use `openclaw agent --local ...` for automation.", + ); + process.exitCode = 1; + return; + } + const { launchTuiCli } = await import("../tui/tui-launch.js"); + const tuiOptions = bareRootLaunchTarget.local + ? { deliver: false, local: true } + : { deliver: false }; + const tuiLaunchOptions: { gatewayUrl?: string; authSource?: "config" } = {}; + if (bareRootLaunchTarget.gatewayUrl) { + tuiLaunchOptions.gatewayUrl = bareRootLaunchTarget.gatewayUrl; + } + if (bareRootLaunchTarget.authSource) { + tuiLaunchOptions.authSource = bareRootLaunchTarget.authSource; + } + await launchTuiCli(tuiOptions, tuiLaunchOptions); + return; + } if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error( 'Crestodian needs an interactive TTY. Use `openclaw crestodian --message "status"` for one command.', diff --git a/src/commands/auth-choice-options.static.ts b/src/commands/auth-choice-options.static.ts index e581c81b71b9..8da7f5bb77cd 100644 --- a/src/commands/auth-choice-options.static.ts +++ b/src/commands/auth-choice-options.static.ts @@ -6,6 +6,7 @@ export type AuthChoiceOption = { value: AuthChoice; label: string; hint?: string; + providerId?: string; groupId?: AuthChoiceGroupId; groupLabel?: string; groupHint?: string; @@ -18,6 +19,7 @@ export type AuthChoiceGroup = { value: AuthChoiceGroupId; label: string; hint?: string; + providerIds?: string[]; options: AuthChoiceOption[]; }; diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index 9ebe360a175e..7b7a0849324f 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -37,6 +37,7 @@ vi.mock("../flows/provider-flow.js", () => ({ ...resolveManifestProviderAuthChoices() .filter((choice) => includesOnboardingScope(choice.onboardingScopes, scope)) .map((choice) => ({ + providerId: choice.providerId, option: { value: choice.choiceId, label: choice.choiceLabel, @@ -62,6 +63,7 @@ vi.mock("../flows/provider-flow.js", () => ({ ...resolveProviderWizardOptions() .filter((option) => includesOnboardingScope(option.onboardingScopes, scope)) .map((option) => ({ + providerId: option.groupId, option: { value: option.value, label: option.label, @@ -625,6 +627,7 @@ describe("buildAuthChoiceOptions", () => { "openai-chatgpt-device-code", "openai-api-key", ]); + expect(openAIGroup.providerIds).toEqual(["openai"]); expect(openAIGroup.options[0]?.onboardingFeatured).toBe(true); }); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 489008c16d03..46e1f3f5deca 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -56,6 +56,7 @@ function resolveProviderChoiceOptions(params?: { Object.assign( {}, { value: contribution.option.value as AuthChoice, label: contribution.option.label }, + { providerId: contribution.providerId }, contribution.option.hint ? { hint: contribution.option.hint } : {}, contribution.option.assistantPriority !== undefined ? { assistantPriority: contribution.option.assistantPriority } @@ -156,12 +157,17 @@ export function buildAuthChoiceGroups(params: { const existing = groupsById.get(option.groupId); if (existing) { existing.options.push(option); + if (option.providerId) { + existing.providerIds = uniqueStrings([...(existing.providerIds ?? []), option.providerId]); + } continue; } + const providerIds = option.providerId ? [option.providerId] : []; groupsById.set(option.groupId, { value: option.groupId, label: option.groupLabel, ...(option.groupHint ? { hint: option.groupHint } : {}), + ...(providerIds.length > 0 ? { providerIds } : {}), options: [option], }); } diff --git a/src/commands/auth-choice-prompt.test.ts b/src/commands/auth-choice-prompt.test.ts new file mode 100644 index 000000000000..e1ce108aa93c --- /dev/null +++ b/src/commands/auth-choice-prompt.test.ts @@ -0,0 +1,179 @@ +// Grouped auth-choice prompt tests cover configured-provider setup affordances. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; +import type { WizardPrompter, WizardSelectParams } from "../wizard/prompts.js"; +import type { AuthChoiceGroup } from "./auth-choice-options.static.js"; +import { KEEP_CURRENT_AUTH_CHOICE, promptAuthChoiceGrouped } from "./auth-choice-prompt.js"; + +const buildAuthChoiceGroups = vi.hoisted(() => vi.fn()); +const compareAuthChoiceGroups = vi.hoisted(() => + vi.fn((a: AuthChoiceGroup, b: AuthChoiceGroup) => a.label.localeCompare(b.label)), +); + +vi.mock("./auth-choice-options.js", () => ({ + buildAuthChoiceGroups, + compareAuthChoiceGroups, +})); + +const EMPTY_STORE: AuthProfileStore = { version: 1, profiles: {} }; + +function createPromptHarness( + onSelect: (params: WizardSelectParams) => Promise, +): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(onSelect) as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm: vi.fn(async () => true), + progress: vi.fn(() => ({ + update: vi.fn(), + stop: vi.fn(), + })), + }; +} + +function openAIGroup(options?: Partial): AuthChoiceGroup { + return { + value: "openai", + label: "OpenAI", + providerIds: ["openai"], + options: [ + { + value: "openai", + label: "ChatGPT Login", + onboardingFeatured: true, + }, + { + value: "openai-api-key", + label: "OpenAI API Key", + }, + ], + ...options, + }; +} + +describe("promptAuthChoiceGrouped", () => { + beforeEach(() => { + buildAuthChoiceGroups.mockReset(); + compareAuthChoiceGroups.mockClear(); + }); + + it("marks the configured provider and offers keep current config first", async () => { + buildAuthChoiceGroups.mockReturnValue({ + groups: [ + openAIGroup(), + { + value: "anthropic", + label: "Anthropic", + providerIds: ["anthropic"], + options: [ + { + value: "apiKey", + label: "Anthropic API Key", + onboardingFeatured: true, + }, + ], + }, + ], + skipOption: { value: "skip", label: "Skip for now" }, + }); + let providerOptions: Array<{ value: unknown; label: string; hint?: string }> = []; + let methodOptions: Array<{ value: unknown; label: string; hint?: string }> = []; + const prompter = createPromptHarness(async (params) => { + if (params.message === "Model/auth provider") { + providerOptions = params.options; + return "openai"; + } + if (params.message === "OpenAI auth method") { + methodOptions = params.options; + return KEEP_CURRENT_AUTH_CHOICE; + } + throw new Error(`unexpected prompt ${params.message}`); + }); + + const result = await promptAuthChoiceGrouped({ + prompter, + store: EMPTY_STORE, + includeSkip: true, + allowKeepCurrentProvider: true, + config: { + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5", + }, + }, + }, + }, + }); + + expect(result).toBe(KEEP_CURRENT_AUTH_CHOICE); + expect(providerOptions).toContainEqual({ + value: "openai", + label: "OpenAI (currently configured)", + hint: undefined, + }); + expect(methodOptions[0]).toEqual({ + value: KEEP_CURRENT_AUTH_CHOICE, + label: "Keep current config", + hint: "Keep openai/gpt-5.5", + }); + expect(methodOptions.map((option) => option.value)).toEqual([ + KEEP_CURRENT_AUTH_CHOICE, + "openai", + "openai-api-key", + "__back", + ]); + }); + + it("does not show keep current config for a different provider", async () => { + buildAuthChoiceGroups.mockReturnValue({ + groups: [openAIGroup()], + skipOption: { value: "skip", label: "Skip for now" }, + }); + let providerOptions: Array<{ value: unknown; label: string; hint?: string }> = []; + let methodOptions: Array<{ value: unknown; label: string; hint?: string }> = []; + const prompter = createPromptHarness(async (params) => { + if (params.message === "Model/auth provider") { + providerOptions = params.options; + return "openai"; + } + if (params.message === "OpenAI auth method") { + methodOptions = params.options; + return "openai-api-key"; + } + throw new Error(`unexpected prompt ${params.message}`); + }); + + const result = await promptAuthChoiceGrouped({ + prompter, + store: EMPTY_STORE, + includeSkip: true, + allowKeepCurrentProvider: true, + config: { + agents: { + defaults: { + model: { + primary: "anthropic/claude-sonnet-4.6", + }, + }, + }, + }, + }); + + expect(result).toBe("openai-api-key"); + expect(providerOptions).toContainEqual({ + value: "openai", + label: "OpenAI", + hint: undefined, + }); + expect(methodOptions.map((option) => option.value)).toEqual([ + "openai", + "openai-api-key", + "__back", + ]); + }); +}); diff --git a/src/commands/auth-choice-prompt.ts b/src/commands/auth-choice-prompt.ts index 402ec1dcfde7..5a088060e6b0 100644 --- a/src/commands/auth-choice-prompt.ts +++ b/src/commands/auth-choice-prompt.ts @@ -1,5 +1,7 @@ // Interactive grouped auth-choice prompt used by onboarding and agent setup. +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; import { buildAuthChoiceGroups, compareAuthChoiceGroups } from "./auth-choice-options.js"; @@ -8,45 +10,103 @@ import type { AuthChoice } from "./onboard-types.js"; const BACK_VALUE = "__back"; const MORE_VALUE = "__more"; +export const KEEP_CURRENT_AUTH_CHOICE = "__keep-current"; -type AuthChoiceOrBack = AuthChoice | typeof BACK_VALUE; - -function isGroupFeatured(group: AuthChoiceGroup): boolean { - return group.options.some((option) => option.onboardingFeatured); -} - -function groupToOption(group: AuthChoiceGroup): WizardSelectOption { - return { value: group.value, label: group.label, hint: group.hint }; -} - -/** Prompt for a provider group and auth method, with fallback flat selection when needed. */ -export async function promptAuthChoiceGrouped(params: { +type KeepCurrentAuthChoice = typeof KEEP_CURRENT_AUTH_CHOICE; +type PromptAuthChoiceResult = AuthChoice | KeepCurrentAuthChoice; +type AuthChoiceOrBack = PromptAuthChoiceResult | typeof BACK_VALUE; +type PromptAuthChoiceGroupedParams = { prompter: WizardPrompter; store: AuthProfileStore; includeSkip: boolean; config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; -}): Promise { + allowKeepCurrentProvider?: boolean; +}; + +function isGroupFeatured(group: AuthChoiceGroup): boolean { + return group.options.some((option) => option.onboardingFeatured); +} + +function resolveConfiguredModelRef(config?: OpenClawConfig): string | undefined { + return resolveAgentModelPrimaryValue(config?.agents?.defaults?.model); +} + +function resolveConfiguredProvider(config?: OpenClawConfig): string | undefined { + const modelRef = resolveConfiguredModelRef(config); + const slashIndex = modelRef?.indexOf("/") ?? -1; + if (!modelRef || slashIndex <= 0) { + return undefined; + } + const provider = normalizeProviderId(modelRef.slice(0, slashIndex)); + return provider || undefined; +} + +function groupMatchesProvider(group: AuthChoiceGroup, provider: string | undefined): boolean { + if (!provider) { + return false; + } + const candidates = [group.value, ...(group.providerIds ?? [])]; + return candidates.some((candidate) => normalizeProviderId(candidate) === provider); +} + +function groupToOption( + group: AuthChoiceGroup, + configuredProvider: string | undefined, +): WizardSelectOption { + const configured = groupMatchesProvider(group, configuredProvider); + return { + value: group.value, + label: configured ? `${group.label} (currently configured)` : group.label, + hint: group.hint, + }; +} + +/** Prompt for a provider group and auth method, with fallback flat selection when needed. */ +export function promptAuthChoiceGrouped( + params: PromptAuthChoiceGroupedParams & { allowKeepCurrentProvider: true }, +): Promise; +export function promptAuthChoiceGrouped(params: PromptAuthChoiceGroupedParams): Promise; +export async function promptAuthChoiceGrouped( + params: PromptAuthChoiceGroupedParams, +): Promise { const { groups, skipOption } = buildAuthChoiceGroups(params); const availableGroups = groups.filter((group) => group.options.length > 0); const groupById = new Map(availableGroups.map((group) => [group.value, group] as const)); const featuredGroups = availableGroups.filter(isGroupFeatured).toSorted(compareAuthChoiceGroups); const moreGroups = [...availableGroups].toSorted(compareAuthChoiceGroups); + const configuredModelRef = resolveConfiguredModelRef(params.config); + const configuredProvider = params.allowKeepCurrentProvider + ? resolveConfiguredProvider(params.config) + : undefined; const pickMethod = async (group: AuthChoiceGroup): Promise => { - if (group.options.length === 1) { + const keepCurrentOption = groupMatchesProvider(group, configuredProvider) + ? ({ + value: KEEP_CURRENT_AUTH_CHOICE, + label: "Keep current config", + ...(configuredModelRef ? { hint: `Keep ${configuredModelRef}` } : {}), + } satisfies WizardSelectOption) + : undefined; + if (group.options.length === 1 && !keepCurrentOption) { return group.options[0].value; } return (await params.prompter.select({ message: `${group.label} auth method`, - options: [...group.options, { value: BACK_VALUE, label: "Back" }], + options: [ + ...(keepCurrentOption ? [keepCurrentOption] : []), + ...group.options, + { value: BACK_VALUE, label: "Back" }, + ], })) as AuthChoiceOrBack; }; const pickFromMore = async (): Promise => { while (true) { - const options: WizardSelectOption[] = moreGroups.map(groupToOption); + const options: WizardSelectOption[] = moreGroups.map((group) => + groupToOption(group, configuredProvider), + ); options.push({ value: BACK_VALUE, label: "Back" }); const selection = await params.prompter.select({ message: "Model/auth provider", @@ -70,9 +130,11 @@ export async function promptAuthChoiceGrouped(params: { // No featured groups available → fall back to the original flat list so we // never strand the user behind an empty "More…" indirection. - const runFlat = async (): Promise => { + const runFlat = async (): Promise => { while (true) { - const flatOptions: WizardSelectOption[] = moreGroups.map(groupToOption); + const flatOptions: WizardSelectOption[] = moreGroups.map((group) => + groupToOption(group, configuredProvider), + ); if (skipOption) { flatOptions.push({ value: skipOption.value, label: skipOption.label }); } @@ -105,7 +167,9 @@ export async function promptAuthChoiceGrouped(params: { } while (true) { - const topTier: WizardSelectOption[] = featuredGroups.map(groupToOption); + const topTier: WizardSelectOption[] = featuredGroups.map((group) => + groupToOption(group, configuredProvider), + ); topTier.push({ value: MORE_VALUE, label: "More…" }); if (skipOption) { topTier.push({ value: skipOption.value, label: skipOption.label }); diff --git a/src/commands/onboard-hooks.test.ts b/src/commands/onboard-hooks.test.ts index d9c51f2247ed..3192047fa093 100644 --- a/src/commands/onboard-hooks.test.ts +++ b/src/commands/onboard-hooks.test.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { HookStatusEntry, HookStatusReport } from "../hooks/hooks-status.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -import { setupInternalHooks } from "./onboard-hooks.js"; +import { enableDefaultOnboardingInternalHooks, setupInternalHooks } from "./onboard-hooks.js"; // Mock hook discovery modules vi.mock("../hooks/hooks-status.js", () => ({ @@ -22,6 +22,62 @@ describe("onboard-hooks", () => { delete process.env.OPENCLAW_LOCALE; }); + describe("enableDefaultOnboardingInternalHooks", () => { + it("enables only the bundled session-memory entry by default", () => { + const result = enableDefaultOnboardingInternalHooks({}); + + expect(result.hooks?.internal?.enabled).toBeUndefined(); + expect(result.hooks?.internal?.entries).toEqual({ + "session-memory": { enabled: true }, + }); + }); + + it("preserves explicit internal hook disablement", () => { + const cfg: OpenClawConfig = { + hooks: { + internal: { + enabled: false, + }, + }, + }; + + expect(enableDefaultOnboardingInternalHooks(cfg)).toBe(cfg); + }); + + it("preserves an explicit session-memory disablement", () => { + const cfg: OpenClawConfig = { + hooks: { + internal: { + entries: { + "session-memory": { enabled: false }, + }, + }, + }, + }; + + expect(enableDefaultOnboardingInternalHooks(cfg)).toBe(cfg); + }); + + it("preserves existing per-hook settings when enabling session-memory", () => { + const result = enableDefaultOnboardingInternalHooks({ + hooks: { + internal: { + entries: { + "session-memory": { + messages: 25, + }, + }, + }, + }, + }); + + expect(result.hooks?.internal?.entries?.["session-memory"]).toEqual({ + enabled: true, + messages: 25, + }); + }); + }); + const createMockPrompter = (multiselectValue: string[]): WizardPrompter => ({ confirm: vi.fn().mockResolvedValue(true), note: vi.fn().mockResolvedValue(undefined), diff --git a/src/commands/onboard-hooks.ts b/src/commands/onboard-hooks.ts index 65c74bf61104..da09952589bc 100644 --- a/src/commands/onboard-hooks.ts +++ b/src/commands/onboard-hooks.ts @@ -7,6 +7,43 @@ import type { RuntimeEnv } from "../runtime.js"; import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +const DEFAULT_ONBOARDING_INTERNAL_HOOKS = ["session-memory"] as const; + +export function enableDefaultOnboardingInternalHooks(cfg: OpenClawConfig): OpenClawConfig { + const existingInternal = cfg.hooks?.internal; + if (existingInternal?.enabled === false) { + return cfg; + } + + let changed = false; + const entries = { ...existingInternal?.entries }; + for (const hookName of DEFAULT_ONBOARDING_INTERNAL_HOOKS) { + const entry = entries[hookName]; + if (entry?.enabled === false) { + continue; + } + if (entry?.enabled !== true) { + entries[hookName] = { ...entry, enabled: true }; + changed = true; + } + } + + if (!changed) { + return cfg; + } + + return { + ...cfg, + hooks: { + ...cfg.hooks, + internal: { + ...existingInternal, + entries, + }, + }, + }; +} + /** Prompts for loadable internal hooks and writes selected hook entries. */ export async function setupInternalHooks( cfg: OpenClawConfig, diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 2b35c37f46c1..703160755c3f 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -523,6 +523,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { gateway?: { mode?: string; auth?: { mode?: string; token?: string } }; agents?: { defaults?: { workspace?: string } }; tools?: { profile?: string }; + hooks?: { internal?: { entries?: Record } }; }>(); expect(cfg?.agents?.defaults?.workspace).toBe(workspace); @@ -530,6 +531,31 @@ describe("onboard (non-interactive): gateway and remote auth", () => { expect(cfg?.tools?.profile).toBe("coding"); expect(cfg?.gateway?.auth?.mode).toBe("token"); expect(cfg?.gateway?.auth?.token).toBe(token); + expect(cfg?.hooks?.internal?.entries?.["session-memory"]).toEqual({ enabled: true }); + }); + }, 60_000); + + it("does not auto-enable default hooks when skipHooks is set", async () => { + await withStateDir("state-skip-hooks-", async (stateDir) => { + const workspace = path.join(stateDir, "openclaw"); + + await runNonInteractiveSetup( + { + nonInteractive: true, + mode: "local", + workspace, + authChoice: "skip", + skipHooks: true, + skipSkills: true, + skipHealth: true, + installDaemon: false, + gatewayBind: "loopback", + }, + runtime, + ); + + const cfg = readTestConfig(); + expect(cfg.hooks).toBeUndefined(); }); }, 60_000); @@ -662,11 +688,13 @@ describe("onboard (non-interactive): gateway and remote auth", () => { const cfg = readTestConfig<{ gateway?: { mode?: string; remote?: { url?: string; token?: string } }; + hooks?: { internal?: { entries?: Record } }; }>(); expect(cfg.gateway?.mode).toBe("remote"); expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`); expect(cfg.gateway?.remote?.token).toBe(token); + expect(cfg.hooks?.internal?.entries?.["session-memory"]).toEqual({ enabled: true }); }); }, 60_000); diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 3435cd5b2107..ab4ad27d663a 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -20,6 +20,7 @@ import { resolveControlUiLinks, waitForGatewayReachable, } from "../onboard-helpers.js"; +import { enableDefaultOnboardingInternalHooks } from "../onboard-hooks.js"; import type { OnboardOptions } from "../onboard-types.js"; import { commitNonInteractiveOnboardConfig } from "./config-write.js"; import { applyNonInteractiveGatewayConfig } from "./local/gateway-config.js"; @@ -220,6 +221,9 @@ export async function runNonInteractiveLocalSetup(params: { nextConfig = gatewayResult.nextConfig; nextConfig = applyNonInteractiveSkillsConfig({ nextConfig, opts, runtime }); + if (!opts.skipHooks) { + nextConfig = enableDefaultOnboardingInternalHooks(nextConfig); + } nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); nextConfig = await commitNonInteractiveOnboardConfig({ diff --git a/src/commands/onboard-non-interactive/remote.ts b/src/commands/onboard-non-interactive/remote.ts index 422cb3740697..b7a2ddfaff02 100644 --- a/src/commands/onboard-non-interactive/remote.ts +++ b/src/commands/onboard-non-interactive/remote.ts @@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { applySkipBootstrapConfig } from "../onboard-config.js"; import { applyWizardMetadata } from "../onboard-helpers.js"; +import { enableDefaultOnboardingInternalHooks } from "../onboard-hooks.js"; import type { OnboardOptions } from "../onboard-types.js"; import { commitNonInteractiveOnboardConfig } from "./config-write.js"; @@ -49,6 +50,9 @@ export async function runNonInteractiveRemoteSetup(params: { if (opts.skipBootstrap) { nextConfig = applySkipBootstrapConfig(nextConfig); } + if (!opts.skipHooks) { + nextConfig = enableDefaultOnboardingInternalHooks(nextConfig); + } nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); await commitNonInteractiveOnboardConfig({ nextConfig, diff --git a/src/commands/onboard-skills.test.ts b/src/commands/onboard-skills.test.ts index 050da74e197c..9e6a82b42e3d 100644 --- a/src/commands/onboard-skills.test.ts +++ b/src/commands/onboard-skills.test.ts @@ -41,8 +41,10 @@ function createBundledSkill(params: { name: string; description: string; bins: string[]; + env?: string[]; os?: string[]; installLabel: string; + installKind?: string; }): { name: string; description: string; @@ -78,10 +80,39 @@ function createBundledSkill(params: { disabled: false, blockedByAllowlist: false, eligible: false, - requirements: { bins: params.bins, anyBins: [], env: [], config: [], os: params.os ?? [] }, - missing: { bins: params.bins, anyBins: [], env: [], config: [], os: params.os ?? [] }, + requirements: { + bins: params.bins, + anyBins: [], + env: params.env ?? [], + config: [], + os: params.os ?? [], + }, + missing: { + bins: params.bins, + anyBins: [], + env: params.env ?? [], + config: [], + os: params.os ?? [], + }, configChecks: [], - install: [{ id: "brew", kind: "brew", label: params.installLabel, bins: params.bins }], + install: [ + { + id: params.installKind ?? "brew", + kind: params.installKind ?? "brew", + label: params.installLabel, + bins: params.bins, + }, + ], + }; +} + +function createWorkspaceSkill( + params: Parameters[0], +): ReturnType { + return { + ...createBundledSkill(params), + source: "openclaw-workspace", + bundled: false, }; } @@ -206,7 +237,7 @@ describe("setupSkills", () => { const { prompter, notes } = createPrompter({ multiselect: ["video-frames"] }); await setupSkills({} as OpenClawConfig, "/tmp/ws", runtime, prompter); - expect(prompter.multiselect).toHaveBeenCalled(); + expect(prompter.multiselect).not.toHaveBeenCalled(); expect(mocks.installSkill).toHaveBeenCalledWith( expect.objectContaining({ skillName: "video-frames", installId: "brew" }), ); @@ -215,7 +246,66 @@ describe("setupSkills", () => { }); }); - it("does not recommend Homebrew when user skips installing brew-backed deps", async () => { + it("auto-installs bundled skill dependencies without running workspace skill recipes", async () => { + mockMissingBrewStatus([ + createWorkspaceSkill({ + name: "repo-helper", + description: "Workspace helper", + bins: ["repo-helper"], + installLabel: "Install repo-helper", + }), + createBundledSkill({ + name: "video-frames", + description: "ffmpeg", + bins: ["ffmpeg"], + installLabel: "Install ffmpeg (brew)", + }), + ]); + + const { prompter, notes } = createPrompter({}); + await setupSkills({} as OpenClawConfig, "/tmp/ws", runtime, prompter); + + expect(prompter.multiselect).not.toHaveBeenCalled(); + expect(mocks.installSkill).toHaveBeenCalledTimes(1); + expect(mocks.installSkill).toHaveBeenCalledWith( + expect.objectContaining({ skillName: "video-frames", installId: "brew" }), + ); + const installNote = notes.find((n) => n.message.includes("video-frames")); + expect(installNote?.message).toContain("video-frames"); + expect(installNote?.message).not.toContain("repo-helper"); + }); + + it("uses the requested node manager for node-backed auto installs", async () => { + mockMissingBrewStatus([ + createBundledSkill({ + name: "node-helper", + description: "Node helper", + bins: ["node-helper"], + installLabel: "Install node-helper", + installKind: "node", + }), + ]); + + const { prompter } = createPrompter({}); + const next = await setupSkills({} as OpenClawConfig, "/tmp/ws", runtime, prompter, { + nodeManager: "pnpm", + }); + + expect(next.skills?.install?.nodeManager).toBe("pnpm"); + expect(mocks.installSkill).toHaveBeenCalledWith( + expect.objectContaining({ + skillName: "node-helper", + installId: "node", + config: expect.objectContaining({ + skills: expect.objectContaining({ + install: expect.objectContaining({ nodeManager: "pnpm" }), + }), + }), + }), + ); + }); + + it("recommends Homebrew when brew-backed deps are auto-installed and brew is missing", async () => { if (!supportsHomebrewPrompt) { return; } @@ -251,10 +341,11 @@ describe("setupSkills", () => { }); const brewNote = notes.find((n) => n.title === "Homebrew recommended"); - expect(brewNote).toBeUndefined(); + expect(brewNote).toBeDefined(); + expect(prompter.multiselect).not.toHaveBeenCalled(); }); - it("recommends Homebrew when user selects a brew-backed install and brew is missing", async () => { + it("recommends Homebrew when brew-backed installs run and brew is missing", async () => { if (!supportsHomebrewPrompt) { return; } @@ -304,7 +395,28 @@ describe("setupSkills", () => { const brewNote = notes.find((n) => n.title === "Homebrew recommended"); expect(brewNote).toBeUndefined(); + expect(prompter.multiselect).not.toHaveBeenCalled(); expect(mocks.detectBinary).not.toHaveBeenCalledWith("brew"); }); }); + + it("does not ask for API keys when skills are missing env vars", async () => { + mockMissingBrewStatus([ + createBundledSkill({ + name: "goplaces", + description: "Places lookup", + bins: [], + env: ["GOOGLE_PLACES_API_KEY"], + installLabel: "", + }), + ]); + + const { prompter } = createPrompter({}); + const next = await setupSkills({} as OpenClawConfig, "/tmp/ws", runtime, prompter); + + expect(next).toEqual({}); + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(prompter.text).not.toHaveBeenCalled(); + expect(prompter.multiselect).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/onboard-skills.ts b/src/commands/onboard-skills.ts index 0db3e5a60907..4a5fc6401e7e 100644 --- a/src/commands/onboard-skills.ts +++ b/src/commands/onboard-skills.ts @@ -2,19 +2,19 @@ * Interactive skill dependency setup for onboarding. * * It reports workspace skill readiness, offers safe dependency installs, and - * records per-skill API keys entered during setup. + * leaves per-skill credentials to the agent when a skill actually needs them. */ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveBrewExecutable } from "../infra/brew.js"; import { isContainerEnvironment } from "../infra/container-environment.js"; import type { RuntimeEnv } from "../runtime.js"; -import { patchSkillConfigEntry } from "../skills/config/mutations.js"; import { buildWorkspaceSkillStatus } from "../skills/discovery/status.js"; import { installSkill } from "../skills/lifecycle/install.js"; import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -import { detectBinary, resolveNodeManagerOptions } from "./onboard-helpers.js"; +import { detectBinary } from "./onboard-helpers.js"; +import type { NodeManagerChoice } from "./onboard-types.js"; const HOMEBREW_PROMPT_PLATFORMS = new Set(["darwin", "linux"]); @@ -56,12 +56,40 @@ function isBrewOnlyInstallableSkill(skill: { ); } +function isTrustedAutoInstallableSkill(skill: { bundled: boolean; source: string }): boolean { + // Onboarding can auto-run bundled recipes without another prompt. Workspace + // skill metadata is mutable project input, so those installs stay explicit. + return skill.bundled && skill.source === "openclaw-bundled"; +} + +function isNodeManagerChoice(value: unknown): value is NodeManagerChoice { + return value === "npm" || value === "pnpm" || value === "bun"; +} + +function resolveDefaultNodeManager( + config: OpenClawConfig, + requested: NodeManagerChoice | undefined, + runtime: RuntimeEnv, +): NodeManagerChoice { + if (requested !== undefined) { + if (!isNodeManagerChoice(requested)) { + runtime.error('Invalid --node-manager. Use "npm", "pnpm", or "bun".'); + runtime.exit(1); + return "npm"; + } + return requested; + } + const existing = config.skills?.install?.nodeManager; + return existing === "npm" || existing === "pnpm" || existing === "bun" ? existing : "npm"; +} + /** Runs the interactive skills setup step and returns the updated config. */ export async function setupSkills( cfg: OpenClawConfig, workspaceDir: string, runtime: RuntimeEnv, prompter: WizardPrompter, + options: { nodeManager?: NodeManagerChoice } = {}, ): Promise { const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg }); const eligible = report.skills.filter((s) => s.eligible); @@ -83,16 +111,11 @@ export async function setupSkills( t("wizard.skills.statusTitle"), ); - const shouldConfigure = await prompter.confirm({ - message: t("wizard.skills.configure"), - initialValue: true, - }); - if (!shouldConfigure) { - return cfg; - } - const baseInstallable = missing.filter( - (skill) => skill.install.length > 0 && skill.missing.bins.length > 0, + (skill) => + skill.install.length > 0 && + skill.missing.bins.length > 0 && + isTrustedAutoInstallableSkill(skill), ); let brewAvailable: boolean | undefined; const detectBrewOnce = async () => { @@ -128,27 +151,11 @@ export async function setupSkills( return next; } if (installable.length > 0) { - const toInstall = await prompter.multiselect({ - message: t("wizard.skills.installDeps"), - options: [ - { - value: "__skip__", - label: t("common.skipForNow"), - hint: t("wizard.skills.skipDepsHint"), - }, - ...installable.map((skill) => ({ - value: skill.name, - label: `${skill.emoji ?? "🧩"} ${skill.name}`, - hint: formatSkillHint(skill), - })), - ], - }); - - const selected = toInstall.filter((name) => name !== "__skip__"); - - const selectedSkills = selected - .map((name) => installable.find((s) => s.name === name)) - .filter((item): item is (typeof installable)[number] => Boolean(item)); + await prompter.note( + installable.map((skill) => `${skill.name}: ${formatSkillHint(skill)}`).join("\n"), + t("wizard.skills.installDeps"), + ); + const selectedSkills = installable; const needsBrewPrompt = supportsHomebrewPrompt(process.platform) && @@ -160,22 +167,12 @@ export async function setupSkills( [ "Many skill dependencies are shipped via Homebrew.", "Without brew, you'll need to build from source or download releases manually.", + "", + "Install Homebrew:", + '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', ].join("\n"), t("wizard.skills.homebrewRecommendedTitle"), ); - const showBrewInstall = await prompter.confirm({ - message: t("wizard.skills.homebrewCommand"), - initialValue: true, - }); - if (showBrewInstall) { - await prompter.note( - [ - "Run:", - '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', - ].join("\n"), - t("wizard.skills.homebrewInstallTitle"), - ); - } } const needsNodeManagerPrompt = selectedSkills.some((skill) => @@ -184,10 +181,7 @@ export async function setupSkills( if (needsNodeManagerPrompt) { // Persist the package manager before invoking installers so node recipes // and later skill lifecycle commands agree on the selected tool. - const nodeManager = (await prompter.select({ - message: t("wizard.skills.nodeManager"), - options: resolveNodeManagerOptions(), - })) as "npm" | "pnpm" | "bun"; + const nodeManager = resolveDefaultNodeManager(next, options.nodeManager, runtime); next = { ...next, skills: { @@ -200,9 +194,8 @@ export async function setupSkills( }; } - for (const name of selected) { - const target = installable.find((s) => s.name === name); - if (!target || target.install.length === 0) { + for (const target of selectedSkills) { + if (target.install.length === 0) { continue; } const installId = target.install[0]?.id; @@ -211,7 +204,7 @@ export async function setupSkills( } // Onboarding installs the primary recipe only; alternative recipes remain // visible through `openclaw skills list --verbose`. - const spin = prompter.progress(t("wizard.skills.installing", { name })); + const spin = prompter.progress(t("wizard.skills.installing", { name: target.name })); const result = await installSkill({ workspaceDir, skillName: target.name, @@ -222,8 +215,8 @@ export async function setupSkills( if (result.ok) { spin.stop( warnings.length > 0 - ? t("wizard.skills.installedWithWarnings", { name }) - : t("wizard.skills.installed", { name }), + ? t("wizard.skills.installedWithWarnings", { name: target.name }) + : t("wizard.skills.installed", { name: target.name }), ); for (const warning of warnings) { runtime.log(warning); @@ -233,7 +226,11 @@ export async function setupSkills( const code = result.code == null ? "" : ` (exit ${result.code})`; const detail = summarizeInstallFailure(result.message); spin.stop( - t("wizard.skills.installFailed", { name, code, detail: detail ? ` - ${detail}` : "" }), + t("wizard.skills.installFailed", { + name: target.name, + code, + detail: detail ? ` - ${detail}` : "", + }), ); for (const warning of warnings) { runtime.log(warning); @@ -250,26 +247,5 @@ export async function setupSkills( } } - for (const skill of missing) { - if (!skill.primaryEnv || skill.missing.env.length === 0) { - continue; - } - // API keys entered here patch the skill entry, not process.env, so future - // agent sessions can resolve the same skill configuration. - const wantsKey = await prompter.confirm({ - message: t("wizard.skills.setEnv", { env: skill.primaryEnv, name: skill.name }), - initialValue: false, - }); - if (!wantsKey) { - continue; - } - const apiKey = await prompter.text({ - message: t("wizard.skills.enterEnv", { env: skill.primaryEnv }), - validate: (value) => (value?.trim() ? undefined : t("common.required")), - sensitive: true, - }); - next = patchSkillConfigEntry(next, skill.skillKey, { apiKey }); - } - return next; } diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index f52dbacdd142..9257df65b421 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -39,6 +39,8 @@ export const FIELD_HELP: Record = { "Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce setup steps when verifying setup regressions.", "wizard.lastRunMode": 'Wizard execution mode recorded as "local" or "remote" for the most recent setup flow. Use this to understand whether setup targeted direct local runtime or remote gateway topology.', + "wizard.securityAcknowledgedAt": + "ISO timestamp for when the setup security acknowledgement was accepted on this config. Setup uses this to avoid repeating the acknowledgement on later wizard runs.", diagnostics: "Diagnostics controls for targeted tracing, telemetry export, and cache inspection during debugging. Keep baseline diagnostics minimal in production and enable deeper signals only when investigating issues.", "diagnostics.memoryPressureSnapshot": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 87e36957b82e..9c44a95ddbda 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -22,6 +22,7 @@ export const FIELD_LABELS: Record = { "wizard.lastRunCommit": "Wizard Last Run Commit", "wizard.lastRunCommand": "Wizard Last Run Command", "wizard.lastRunMode": "Wizard Last Run Mode", + "wizard.securityAcknowledgedAt": "Wizard Security Acknowledgement Timestamp", diagnostics: "Diagnostics", "diagnostics.otel": "OpenTelemetry", "diagnostics.cacheTrace": "Cache Trace", diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 2c8300023e1e..65d19f025de0 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -127,6 +127,8 @@ export type OpenClawConfig = { lastRunCommand?: string; /** Whether the last wizard run configured a local or remote install. */ lastRunMode?: "local" | "remote"; + /** ISO timestamp when the setup security acknowledgement was accepted on this config. */ + securityAcknowledgedAt?: string; }; /** Diagnostics, tracing, and stability debugging settings. */ diagnostics?: DiagnosticsConfig; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index e358363a44a8..67649d9c4c65 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -581,6 +581,7 @@ export const OpenClawSchema = z lastRunCommit: z.string().optional(), lastRunCommand: z.string().optional(), lastRunMode: z.union([z.literal("local"), z.literal("remote")]).optional(), + securityAcknowledgedAt: z.string().optional(), }) .strict() .optional(), diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 05108695b109..8c48e3df14b0 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -342,6 +342,42 @@ describe("setupChannels workspace shadow exclusion", () => { expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); }); + it("puts skip first and selected by default in QuickStart channel selection", async () => { + resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); + const select = vi.fn(async () => "__skip__"); + + await setupChannels( + {} as never, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + } as never, + { + deferStatusUntilSelection: true, + quickstartDefaults: true, + skipConfirm: true, + }, + ); + + const prompt = callArg<{ + message?: string; + options?: Array<{ value: string; label: string }>; + initialValue?: string; + searchable?: boolean; + }>(select); + expect(prompt.message).toBe("Select channel (QuickStart)"); + expect(prompt.options?.[0]).toEqual( + expect.objectContaining({ + value: "__skip__", + label: "Skip for now", + }), + ); + expect(prompt.initialValue).toBe("__skip__"); + expect(prompt.searchable).toBe(true); + }); + it("keeps already-active setup plugins in the deferred picker without registry fallback", async () => { const activePlugin = { ...makeSetupPlugin({ id: "custom-chat", label: "Custom Chat" }), diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index fd282573d5f5..c6c157819fe3 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -741,28 +741,30 @@ export async function setupChannels( }; if (options?.quickstartDefaults) { + const skipValue = "__skip__" as const; + const quickstartInitialValue = options?.initialSelection?.[0] ?? skipValue; while (true) { const { entries, catalogById } = getChannelEntries(); const choice = await prompter.select({ message: t("wizard.channels.selectQuickstart"), options: [ - ...resolveChannelSetupSelectionContributions({ - entries, - statusByChannel: buildStatusByChannelForSelection(catalogById), - resolveDisabledHint, - }).map((contribution) => contribution.option), { - value: "__skip__", + value: skipValue, label: t("common.skipForNow"), hint: t("wizard.channels.skipLaterHint", { command: formatCliCommand("openclaw channels add"), }), }, + ...resolveChannelSetupSelectionContributions({ + entries, + statusByChannel: buildStatusByChannelForSelection(catalogById), + resolveDisabledHint, + }).map((contribution) => contribution.option), ], - initialValue: quickstartDefault, + initialValue: quickstartInitialValue, searchable: true, }); - if (choice === "__skip__") { + if (choice === skipValue) { break; } if ((await handleChannelChoice(choice)) === "done") { diff --git a/src/flows/search-setup.test.ts b/src/flows/search-setup.test.ts index 3f58da30bdcb..88e066cf8956 100644 --- a/src/flows/search-setup.test.ts +++ b/src/flows/search-setup.test.ts @@ -8,6 +8,9 @@ import { runSearchSetupFlow } from "./search-setup.js"; const authMocks = vi.hoisted(() => ({ hasAuthProfileForProvider: vi.fn((_params: { provider: string; type?: string }) => false), })); +const webSearchProviderMocks = vi.hoisted(() => ({ + resolvePluginWebSearchProviders: vi.fn(), +})); vi.mock("../agents/tools/model-config.helpers.js", () => ({ hasAuthProfileForProvider: authMocks.hasAuthProfileForProvider, @@ -104,8 +107,23 @@ const mockGrokProvider = vi.hoisted(() => ({ }, })); +const mockCodexProvider = vi.hoisted(() => ({ + id: "codex", + pluginId: "codex", + label: "Codex Hosted Search", + hint: "Grounded answers through your Codex app-server account", + docsUrl: "https://docs.openclaw.ai/tools/web", + requiresCredential: false, + credentialLabel: "Codex app-server account", + placeholder: "", + signupUrl: "https://chatgpt.com", + envVars: [], + onboardingScopes: ["text-inference"], + credentialPath: "", +})); + vi.mock("../plugins/web-search-providers.runtime.js", () => ({ - resolvePluginWebSearchProviders: () => [mockGrokProvider], + resolvePluginWebSearchProviders: webSearchProviderMocks.resolvePluginWebSearchProviders, })); const ensureOnboardingPluginInstalled = vi.hoisted(() => @@ -170,6 +188,8 @@ describe("runSearchSetupFlow", () => { ensureOnboardingPluginInstalled.mockClear(); authMocks.hasAuthProfileForProvider.mockReset(); authMocks.hasAuthProfileForProvider.mockReturnValue(false); + webSearchProviderMocks.resolvePluginWebSearchProviders.mockReset(); + webSearchProviderMocks.resolvePluginWebSearchProviders.mockReturnValue([mockGrokProvider]); }); it("localizes setup copy for web search provider selection", async () => { @@ -230,6 +250,66 @@ describe("runSearchSetupFlow", () => { expect(xaiConfig?.xSearch?.model).toBe("grok-4-1-fast"); }); + it("shows provider notes in every search provider row label", async () => { + const select = vi.fn().mockResolvedValueOnce("__skip__"); + const prompter = createWizardPrompter({ + select: select as never, + }); + + await runSearchSetupFlow({ plugins: { allow: ["xai"] } }, createNonExitingRuntime(), prompter); + + const options = select.mock.calls[0]?.[0]?.options as + | Array<{ value: string; label?: string; hint?: string }> + | undefined; + const grokOption = options?.find((option) => option.value === "grok"); + + expect(grokOption).toEqual( + expect.objectContaining({ + label: "Grok (Search with xAI · API key required)", + }), + ); + expect(grokOption).not.toHaveProperty("hint"); + }); + + it("recommends Codex hosted search first when the configured model uses Codex", async () => { + webSearchProviderMocks.resolvePluginWebSearchProviders.mockReturnValue([ + mockGrokProvider, + mockCodexProvider, + ]); + const select = vi.fn().mockResolvedValueOnce("__skip__"); + const prompter = createWizardPrompter({ + select: select as never, + }); + + await runSearchSetupFlow( + { + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5", + }, + }, + }, + }, + createNonExitingRuntime(), + prompter, + ); + + const prompt = select.mock.calls[0]?.[0] as + | { + options?: Array<{ value: string; label?: string }>; + initialValue?: string; + } + | undefined; + expect(prompt?.options?.[0]).toEqual( + expect.objectContaining({ + value: "codex", + label: expect.stringContaining("Codex Hosted Search"), + }), + ); + expect(prompt?.initialValue).toBe("codex"); + }); + it("uses existing xAI OAuth for Grok web search without prompting for an API key", async () => { authMocks.hasAuthProfileForProvider.mockImplementation( ({ provider, type }) => provider === "xai" && (!type || type === "oauth"), diff --git a/src/flows/search-setup.ts b/src/flows/search-setup.ts index 75107e88b4a1..770c29f42264 100644 --- a/src/flows/search-setup.ts +++ b/src/flows/search-setup.ts @@ -1,8 +1,11 @@ // Search setup flow configures web search providers and defaults. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { resolveDefaultAgentDir } from "../agents/agent-scope-config.js"; +import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js"; +import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { hasAuthProfileForProvider } from "../agents/tools/model-config.helpers.js"; import type { SecretInputMode } from "../commands/onboard-types.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { DEFAULT_SECRET_PROVIDER_ALIAS, @@ -47,6 +50,7 @@ type SearchProviderSetupContribution = FlowContribution & { const SEARCH_INSTALL_CATALOG_ENTRY = Symbol("search-install-catalog-entry"); const WEB_SEARCH_DOCS_URL = "https://docs.openclaw.ai/tools/web"; +const CODEX_HOSTED_SEARCH_PROVIDER_ID = "codex"; type SearchProviderEntryWithInstall = PluginWebSearchProviderEntry & { [SEARCH_INSTALL_CATALOG_ENTRY]?: WebSearchInstallCatalogEntry; @@ -141,6 +145,38 @@ function resolveSearchProviderSetupContributions( ); } +function defaultModelUsesCodexRuntime(config: OpenClawConfig): boolean { + const configuredPrimary = resolveAgentModelPrimaryValue(config.agents?.defaults?.model); + if (!configuredPrimary) { + return false; + } + const defaultModel = resolveDefaultModelForAgent({ cfg: config }); + if (defaultModel.provider === CODEX_HOSTED_SEARCH_PROVIDER_ID) { + return true; + } + return ( + resolveAgentHarnessPolicy({ + provider: defaultModel.provider, + modelId: defaultModel.model, + config, + }).runtime === "codex" + ); +} + +function prioritizeSearchProvider( + providers: readonly PluginWebSearchProviderEntry[], + preferredProvider: string | undefined, +): PluginWebSearchProviderEntry[] { + if (!preferredProvider) { + return [...providers]; + } + const preferred = providers.find((provider) => provider.id === preferredProvider); + if (!preferred) { + return [...providers]; + } + return [preferred, ...providers.filter((provider) => provider.id !== preferredProvider)]; +} + function resolveSearchProviderEntry( config: OpenClawConfig, provider: SearchProvider, @@ -184,6 +220,11 @@ function providerIsReady( return hasExistingKey(config, entry.id) || hasKeyInEnv(entry); } +function formatSearchProviderOptionLabel(label: string, note: string): string { + const normalizedNote = normalizeOptionalString(note); + return normalizedNote ? `${label} (${normalizedNote})` : label; +} + function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown { const entry = resolveSearchProviderEntry(config, provider); return entry?.getConfiguredCredentialValue?.(config); @@ -413,7 +454,14 @@ export async function runSearchSetupFlow( prompter: WizardPrompter, opts?: SetupSearchOptions, ): Promise { - const providerOptions = resolveSearchProviderOptions(config); + const availableProviderOptions = resolveSearchProviderOptions(config); + const codexRecommended = + defaultModelUsesCodexRuntime(config) && + availableProviderOptions.some((entry) => entry.id === CODEX_HOSTED_SEARCH_PROVIDER_ID); + const providerOptions = prioritizeSearchProvider( + availableProviderOptions, + codexRecommended ? CODEX_HOSTED_SEARCH_PROVIDER_ID : undefined, + ); if (providerOptions.length === 0) { await prompter.note( [ @@ -441,6 +489,9 @@ export async function runSearchSetupFlow( if (existingProvider && providerOptions.some((entry) => entry.id === existingProvider)) { return existingProvider; } + if (codexRecommended) { + return CODEX_HOSTED_SEARCH_PROVIDER_ID; + } // Mirror runtime auto-detect only when it has a concrete configured signal. // Keyless providers are selectable, but never preselected; pressing through // setup should not opt the user into a third-party search destination. @@ -475,13 +526,14 @@ export async function runSearchSetupFlow( })(); const options = providerOptions.map((entry) => { - const hint = + const credentialHint = entry.requiresCredential === false - ? `${entry.hint} · ${t("wizard.search.keyFree")}` + ? t("wizard.search.keyFree") : providerIsReady(config, entry) - ? `${entry.hint} · ${t("wizard.search.configured")}` - : entry.hint; - return { value: entry.id, label: entry.label, hint }; + ? t("wizard.search.configured") + : t("wizard.search.apiKeyRequired"); + const hint = [normalizeOptionalString(entry.hint), credentialHint].filter(Boolean).join(" · "); + return { value: entry.id, label: formatSearchProviderOptionLabel(entry.label, hint) }; }); const choice = await prompter.select({ diff --git a/src/plugins/migration-provider-runtime.ts b/src/plugins/migration-provider-runtime.ts index 62d2129912e4..481f170f224b 100644 --- a/src/plugins/migration-provider-runtime.ts +++ b/src/plugins/migration-provider-runtime.ts @@ -53,11 +53,13 @@ function mergeMigrationProviders( export function ensureStandaloneMigrationProviderRegistryLoaded( params: { cfg?: OpenClawConfig; + providerId?: string; } = {}, ): void { const resolution = resolveManifestContractRuntimePluginResolution({ cfg: params.cfg, contract: "migrationProviders", + ...(params.providerId ? { value: params.providerId } : {}), }); if (resolution.pluginIds.length === 0) { return; diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index d807b3e3156e..a06aa6e8946f 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -585,7 +585,21 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); expect(setActivityStatus).toHaveBeenCalledWith("idle"); - expect(tui.requestRender).toHaveBeenCalled(); + expect(tui.requestRender).toHaveBeenCalledWith(true); + }); + + it("force-renders when terminal lifecycle end clears an active status", () => { + const { tui, handleAgentEvent } = createHandlersHarness({ + state: { activeChatRunId: "run-9" }, + }); + + handleAgentEvent({ + runId: "run-9", + stream: "lifecycle", + data: { phase: "end" }, + }); + + expect(tui.requestRender).toHaveBeenCalledWith(true); }); it("does not let delayed finalized-run lifecycle clobber a newer active run", () => { diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 7ce012db2d0d..f578babace07 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -892,12 +892,14 @@ export function createEventHandlers(context: EventHandlerContext) { clearStreamingWatchdog(); setActivityStatus("finishing context"); } + let forceRender = false; if (phase === "end") { postFinalizingRuns.delete(evt.runId); if (!canUpdateActivityStatus) { return; } setActivityStatus("idle"); + forceRender = true; } if (phase === "error") { postFinalizingRuns.delete(evt.runId); @@ -917,8 +919,9 @@ export function createEventHandlers(context: EventHandlerContext) { } else { setActivityStatus("error"); } + forceRender = true; } - tui.requestRender(); + tui.requestRender(forceRender); } }; diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 0b35302b39a2..7b89db03ad6f 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -1245,6 +1245,26 @@ describe("tui session actions", () => { expect(chatLog.restorePendingUsers).toHaveBeenCalledTimes(1); }); + it("force-renders after rebuilding chat history so transient status rows are cleared", async () => { + const loadHistory = vi.fn().mockResolvedValue({ + sessionId: "session-main", + messages: [{ role: "assistant", content: [{ type: "text", text: "reply" }] }], + }); + const requestRender = vi.fn(); + + const { loadHistory: runLoadHistory } = createTestSessionActions({ + client: { + listSessions: vi.fn(), + loadHistory, + } as unknown as TuiBackend, + tui: { requestRender } as unknown as import("@earendil-works/pi-tui").TUI, + }); + + await runLoadHistory(); + + expect(requestRender).toHaveBeenCalledWith(true); + }); + it("hydrates session info from chat history without listing sessions", async () => { const listSessions = vi.fn(); const loadHistory = vi.fn().mockResolvedValue({ diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 9d849de7fdd5..80e971c0f82a 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -399,7 +399,7 @@ export function createSessionActions(context: SessionActionContext) { chatLog.addSystem(`session ${key}`); state.historyLoaded = true; void rememberSessionKey?.(key); - tui.requestRender(); + tui.requestRender(true); }; const applySessionMutationResult = (result?: TuiSessionMutationResult | null): boolean => { @@ -558,7 +558,7 @@ export function createSessionActions(context: SessionActionContext) { } catch (err) { chatLog.addSystem(`history failed: ${String(err)}`); } - tui.requestRender(); + tui.requestRender(true); }; const setSession = async (rawKey: string) => { diff --git a/src/wizard/clack-navigation-prompts.test.ts b/src/wizard/clack-navigation-prompts.test.ts new file mode 100644 index 000000000000..55ab8d4ca780 --- /dev/null +++ b/src/wizard/clack-navigation-prompts.test.ts @@ -0,0 +1,29 @@ +// Navigation prompt tests cover shared onboarding footer copy. +import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../packages/terminal-core/src/ansi.js"; +import { formatNavigationFooter } from "./clack-navigation-prompts.js"; + +describe("formatNavigationFooter", () => { + it("omits the footer when no navigation is possible", () => { + expect(formatNavigationFooter({ canGoBack: false, canGoForward: false })).toBe(""); + }); + + it("renders compact back and forward guidance for navigable onboarding prompts", () => { + expect(stripAnsi(formatNavigationFooter({ canGoBack: true, canGoForward: true }))).toBe( + "← back → next", + ); + }); + + it("renders only the available navigation action", () => { + expect(stripAnsi(formatNavigationFooter({ canGoBack: true, canGoForward: false }))).toBe( + "← back", + ); + expect(stripAnsi(formatNavigationFooter({ canGoBack: false, canGoForward: true }))).toBe( + "→ next", + ); + }); + + it("omits the footer outside prompt navigation", () => { + expect(formatNavigationFooter(undefined)).toBe(""); + }); +}); diff --git a/src/wizard/clack-navigation-prompts.ts b/src/wizard/clack-navigation-prompts.ts new file mode 100644 index 000000000000..453c064222b7 --- /dev/null +++ b/src/wizard/clack-navigation-prompts.ts @@ -0,0 +1,817 @@ +// Clack prompt wrappers that add onboarding navigation footers. +import type { Writable } from "node:stream"; +import { styleText } from "node:util"; +import { + AutocompletePrompt, + ConfirmPrompt, + MultiSelectPrompt, + PasswordPrompt, + SelectPrompt, + settings as clackSettings, + TextPrompt, + wrapTextWithPrefix, +} from "@clack/core"; +import { + S_BAR, + S_BAR_END, + S_CHECKBOX_ACTIVE, + S_CHECKBOX_INACTIVE, + S_CHECKBOX_SELECTED, + S_PASSWORD_MASK, + S_RADIO_ACTIVE, + S_RADIO_INACTIVE, + limitOptions, + symbol as clackSymbol, + symbolBar as clackSymbolBar, + type AutocompleteMultiSelectOptions, + type AutocompleteOptions, + type ConfirmOptions, + type MultiSelectOptions, + type PasswordOptions, + type SelectOptions, + type TextOptions, +} from "@clack/prompts"; +import type { WizardPromptNavigation } from "./prompts.js"; + +type PromptOption = { + value: Value; + label?: string; + hint?: string; + disabled?: boolean; +}; + +type NavigationPromptOptions = { + navigation?: WizardPromptNavigation; + withGuide?: boolean; + output?: Writable; +}; + +function getOptionLabel(option: PromptOption): string { + return option.label ?? String(option.value ?? ""); +} + +function computeLabel(label: string, format: (text: string) => string): string { + if (!label.includes("\n")) { + return format(label); + } + return label + .split("\n") + .map((line) => format(line)) + .join("\n"); +} + +function getFilteredOption(searchText: string, option: PromptOption): boolean { + if (!searchText) { + return true; + } + const term = searchText.toLowerCase(); + return ( + getOptionLabel(option).toLowerCase().includes(term) || + (option.hint ?? "").toLowerCase().includes(term) || + String(option.value).toLowerCase().includes(term) + ); +} + +function getSelectedOptions( + values: Value[], + options: Array>, +): Array> { + return options.filter((option) => values.includes(option.value)); +} + +function adaptOptionFilter( + filter: AutocompleteOptions["filter"] | undefined, +): ((search: string, option: PromptOption) => boolean) | undefined { + return filter ? (search, option) => filter(search, option as never) : undefined; +} + +export function formatNavigationFooter(navigation: WizardPromptNavigation | undefined): string { + if (!navigation || (!navigation.canGoBack && !navigation.canGoForward)) { + return ""; + } + return [ + navigation.canGoBack ? styleText("dim", "← back") : undefined, + navigation.canGoForward ? styleText("dim", "→ next") : undefined, + ] + .filter(Boolean) + .join(" "); +} + +function navigationFooterLines( + guideVisible: boolean, + barStyle: "cyan" | "yellow", + navigation: WizardPromptNavigation | undefined, + extraHints: string[] = [], +): string[] { + const footer = formatNavigationFooter(navigation); + if (!footer) { + return []; + } + const hintLine = [footer, ...extraHints].join(" "); + const prefix = guideVisible ? `${styleText(barStyle, S_BAR)} ` : ""; + return [`${prefix}${hintLine}`]; +} + +function hasGuide(opts: { withGuide?: boolean }): boolean { + return opts.withGuide ?? clackSettings.withGuide; +} + +function selectOptionRenderer(option: PromptOption, state: string): string { + const label = getOptionLabel(option); + switch (state) { + case "disabled": + return `${styleText("gray", S_RADIO_INACTIVE)} ${computeLabel(label, (text) => styleText("gray", text))}${ + option.hint ? ` ${styleText("dim", `(${option.hint})`)}` : "" + }`; + case "selected": + return computeLabel(label, (text) => styleText("dim", text)); + case "active": + return `${styleText("green", S_RADIO_ACTIVE)} ${label}${ + option.hint ? ` ${styleText("dim", `(${option.hint})`)}` : "" + }`; + case "cancelled": + return computeLabel(label, (text) => styleText(["strikethrough", "dim"], text)); + default: + return `${styleText("dim", S_RADIO_INACTIVE)} ${computeLabel(label, (text) => + styleText("dim", text), + )}`; + } +} + +export function selectWithNavigationFooter( + opts: SelectOptions & NavigationPromptOptions, +): Promise { + return new SelectPrompt({ + options: opts.options as Array>, + signal: opts.signal, + input: opts.input, + output: opts.output, + initialValue: opts.initialValue, + render() { + const showGuide = hasGuide(opts); + const titlePrefix = `${clackSymbol(this.state)} `; + const titlePrefixBar = `${clackSymbolBar(this.state)} `; + const messageLines = wrapTextWithPrefix( + opts.output, + opts.message, + titlePrefixBar, + titlePrefix, + ); + const title = `${showGuide ? `${styleText("gray", S_BAR)}\n` : ""}${messageLines}\n`; + + switch (this.state) { + case "submit": { + const submitPrefix = showGuide ? `${styleText("gray", S_BAR)} ` : ""; + const wrappedLines = wrapTextWithPrefix( + opts.output, + selectOptionRenderer(this.options[this.cursor], "selected"), + submitPrefix, + ); + return `${title}${wrappedLines}`; + } + case "cancel": { + const cancelPrefix = showGuide ? `${styleText("gray", S_BAR)} ` : ""; + const wrappedLines = wrapTextWithPrefix( + opts.output, + selectOptionRenderer(this.options[this.cursor], "cancelled"), + cancelPrefix, + ); + return `${title}${wrappedLines}${showGuide ? `\n${styleText("gray", S_BAR)}` : ""}`; + } + default: { + const prefix = showGuide ? `${styleText("cyan", S_BAR)} ` : ""; + const footerLines = [ + ...navigationFooterLines(showGuide, "cyan", opts.navigation, [ + styleText("dim", "↑/↓ option"), + ]), + showGuide ? styleText("cyan", S_BAR_END) : "", + ]; + const titleLineCount = title.split("\n").length; + const footerLineCount = footerLines.length + 1; + return `${title}${prefix}${limitOptions({ + output: opts.output, + cursor: this.cursor, + options: this.options, + maxItems: opts.maxItems, + columnPadding: prefix.length, + rowPadding: titleLineCount + footerLineCount, + style: (item, active) => + selectOptionRenderer( + item, + item.disabled ? "disabled" : active ? "active" : "inactive", + ), + }).join(`\n${prefix}`)}\n${footerLines.join("\n")}\n`; + } + } + }, + }).prompt() as Promise; +} + +export function autocompleteWithNavigationFooter( + opts: AutocompleteOptions & NavigationPromptOptions, +): Promise { + const prompt = new AutocompletePrompt>({ + options: opts.options as Array>, + initialValue: opts.initialValue === undefined ? undefined : [opts.initialValue], + initialUserInput: opts.initialUserInput, + placeholder: opts.placeholder, + filter: adaptOptionFilter(opts.filter) ?? getFilteredOption, + signal: opts.signal, + input: opts.input, + output: opts.output, + validate: opts.validate, + render() { + const showGuide = hasGuide(opts); + const headings = showGuide + ? [styleText("gray", S_BAR), `${clackSymbol(this.state)} ${opts.message}`] + : [`${clackSymbol(this.state)} ${opts.message}`]; + const userInput = this.userInput; + const options = this.options; + const showPlaceholder = userInput === "" && opts.placeholder !== undefined; + const opt = ( + option: PromptOption, + state: "inactive" | "active" | "disabled", + ): string => { + const label = getOptionLabel(option); + const hint = + option.hint && option.value === this.focusedValue + ? styleText("dim", ` (${option.hint})`) + : ""; + switch (state) { + case "active": + return `${styleText("green", S_RADIO_ACTIVE)} ${label}${hint}`; + case "inactive": + return `${styleText("dim", S_RADIO_INACTIVE)} ${styleText("dim", label)}`; + case "disabled": + return `${styleText("gray", S_RADIO_INACTIVE)} ${styleText( + ["strikethrough", "gray"], + label, + )}`; + } + return ""; + }; + + switch (this.state) { + case "submit": { + const selected = getSelectedOptions(this.selectedValues, options); + const label = + selected.length > 0 + ? ` ${styleText("dim", selected.map(getOptionLabel).join(", "))}` + : ""; + const submitPrefix = showGuide ? styleText("gray", S_BAR) : ""; + return `${headings.join("\n")}\n${submitPrefix}${label}`; + } + case "cancel": { + const userInputText = userInput + ? ` ${styleText(["strikethrough", "dim"], userInput)}` + : ""; + const cancelPrefix = showGuide ? styleText("gray", S_BAR) : ""; + return `${headings.join("\n")}\n${cancelPrefix}${userInputText}`; + } + default: { + const barStyle = this.state === "error" ? "yellow" : "cyan"; + const guidePrefix = showGuide ? `${styleText(barStyle, S_BAR)} ` : ""; + const guidePrefixEnd = showGuide ? styleText(barStyle, S_BAR_END) : ""; + const searchText = + this.isNavigating || showPlaceholder + ? opts.placeholder || userInput + ? ` ${styleText("dim", showPlaceholder ? (opts.placeholder ?? "") : userInput)}` + : "" + : ` ${this.userInputWithCursor}`; + const matches = + this.filteredOptions.length !== options.length + ? styleText( + "dim", + ` (${this.filteredOptions.length} match${ + this.filteredOptions.length === 1 ? "" : "es" + })`, + ) + : ""; + const noResults = + this.filteredOptions.length === 0 && userInput + ? [`${guidePrefix}${styleText("yellow", "No matches found")}`] + : []; + const validationError = + this.state === "error" ? [`${guidePrefix}${styleText("yellow", this.error)}`] : []; + if (showGuide) { + headings.push(guidePrefix.trimEnd()); + } + headings.push( + `${guidePrefix}${styleText("dim", "Search:")}${searchText}${matches}`, + ...noResults, + ...validationError, + ); + const instructions = [ + `${styleText("dim", "↑/↓")} to select`, + `${styleText("dim", "Enter:")} confirm`, + `${styleText("dim", "Type:")} to search`, + ]; + const footers = [ + `${guidePrefix}${instructions.join(" • ")}`, + ...navigationFooterLines(showGuide, barStyle, opts.navigation), + guidePrefixEnd, + ]; + const displayOptions = + this.filteredOptions.length === 0 + ? [] + : limitOptions({ + cursor: this.cursor, + options: this.filteredOptions, + columnPadding: showGuide ? 3 : 0, + rowPadding: headings.length + footers.length, + style: (option, active) => + opt(option, option.disabled ? "disabled" : active ? "active" : "inactive"), + maxItems: opts.maxItems, + output: opts.output, + }); + return [ + ...headings, + ...displayOptions.map((option) => `${guidePrefix}${option}`), + ...footers, + ].join("\n"); + } + } + }, + }); + + return prompt.prompt() as Promise; +} + +export function textWithNavigationFooter( + opts: TextOptions & NavigationPromptOptions, +): Promise { + return new TextPrompt({ + validate: opts.validate, + placeholder: opts.placeholder, + defaultValue: opts.defaultValue, + initialValue: opts.initialValue, + output: opts.output, + signal: opts.signal, + input: opts.input, + render() { + const showGuide = hasGuide(opts); + const titlePrefix = `${showGuide ? `${styleText("gray", S_BAR)}\n` : ""}${clackSymbol( + this.state, + )} `; + const title = `${titlePrefix}${opts.message}\n`; + const placeholder = opts.placeholder + ? styleText("inverse", opts.placeholder[0] ?? "") + + styleText("dim", opts.placeholder.slice(1)) + : styleText(["inverse", "hidden"], "_"); + const userInput = !this.userInput ? placeholder : this.userInputWithCursor; + const value = this.value ?? ""; + + switch (this.state) { + case "error": { + const errorText = this.error ? ` ${styleText("yellow", this.error)}` : ""; + const errorPrefix = showGuide ? `${styleText("yellow", S_BAR)} ` : ""; + const errorPrefixEnd = showGuide ? styleText("yellow", S_BAR_END) : ""; + const footerLines = navigationFooterLines(showGuide, "yellow", opts.navigation); + return `${title.trim()}\n${errorPrefix}${userInput}\n${ + footerLines.length ? `${footerLines.join("\n")}\n` : "" + }${errorPrefixEnd}${errorText}\n`; + } + case "submit": { + const valueText = value ? ` ${styleText("dim", value)}` : ""; + const submitPrefix = showGuide ? styleText("gray", S_BAR) : ""; + return `${title}${submitPrefix}${valueText}`; + } + case "cancel": { + const valueText = value ? ` ${styleText(["strikethrough", "dim"], value)}` : ""; + const cancelPrefix = showGuide ? styleText("gray", S_BAR) : ""; + return `${title}${cancelPrefix}${valueText}${value.trim() ? `\n${cancelPrefix}` : ""}`; + } + default: { + const defaultPrefix = showGuide ? `${styleText("cyan", S_BAR)} ` : ""; + const defaultPrefixEnd = showGuide ? styleText("cyan", S_BAR_END) : ""; + const footerLines = navigationFooterLines(showGuide, "cyan", opts.navigation); + return `${title}${defaultPrefix}${userInput}\n${ + footerLines.length ? `${footerLines.join("\n")}\n` : "" + }${defaultPrefixEnd}\n`; + } + } + }, + }).prompt() as Promise; +} + +export function passwordWithNavigationFooter( + opts: PasswordOptions & NavigationPromptOptions, +): Promise { + return new PasswordPrompt({ + validate: opts.validate, + mask: opts.mask ?? S_PASSWORD_MASK, + signal: opts.signal, + input: opts.input, + output: opts.output, + render() { + const showGuide = hasGuide(opts); + const title = `${showGuide ? `${styleText("gray", S_BAR)}\n` : ""}${clackSymbol( + this.state, + )} ${opts.message}\n`; + const userInput = this.userInputWithCursor; + const masked = this.masked; + + switch (this.state) { + case "error": { + const errorPrefix = showGuide ? `${styleText("yellow", S_BAR)} ` : ""; + const errorPrefixEnd = showGuide ? `${styleText("yellow", S_BAR_END)} ` : ""; + const maskedText = masked ?? ""; + if (opts.clearOnError) { + this.clear(); + } + const footerLines = navigationFooterLines(showGuide, "yellow", opts.navigation); + return `${title.trim()}\n${errorPrefix}${maskedText}\n${ + footerLines.length ? `${footerLines.join("\n")}\n` : "" + }${errorPrefixEnd}${styleText("yellow", this.error)}\n`; + } + case "submit": { + const submitPrefix = showGuide ? `${styleText("gray", S_BAR)} ` : ""; + const maskedText = masked ? styleText("dim", masked) : ""; + return `${title}${submitPrefix}${maskedText}`; + } + case "cancel": { + const cancelPrefix = showGuide ? `${styleText("gray", S_BAR)} ` : ""; + const maskedText = masked ? styleText(["strikethrough", "dim"], masked) : ""; + return `${title}${cancelPrefix}${maskedText}${ + masked && showGuide ? `\n${styleText("gray", S_BAR)}` : "" + }`; + } + default: { + const defaultPrefix = showGuide ? `${styleText("cyan", S_BAR)} ` : ""; + const defaultPrefixEnd = showGuide ? styleText("cyan", S_BAR_END) : ""; + const footerLines = navigationFooterLines(showGuide, "cyan", opts.navigation); + return `${title}${defaultPrefix}${userInput}\n${ + footerLines.length ? `${footerLines.join("\n")}\n` : "" + }${defaultPrefixEnd}\n`; + } + } + }, + }).prompt() as Promise; +} + +function multiselectOptionRenderer( + option: PromptOption, + state: + | "inactive" + | "active" + | "selected" + | "active-selected" + | "submitted" + | "cancelled" + | "disabled", +): string { + const label = getOptionLabel(option); + if (state === "disabled") { + return `${styleText("gray", S_CHECKBOX_INACTIVE)} ${computeLabel(label, (str) => + styleText(["strikethrough", "gray"], str), + )}${option.hint ? ` ${styleText("dim", `(${option.hint})`)}` : ""}`; + } + if (state === "active") { + return `${styleText("cyan", S_CHECKBOX_ACTIVE)} ${label}${ + option.hint ? ` ${styleText("dim", `(${option.hint})`)}` : "" + }`; + } + if (state === "selected") { + return `${styleText("green", S_CHECKBOX_SELECTED)} ${computeLabel(label, (text) => + styleText("dim", text), + )}${option.hint ? ` ${styleText("dim", `(${option.hint})`)}` : ""}`; + } + if (state === "cancelled") { + return computeLabel(label, (text) => styleText(["strikethrough", "dim"], text)); + } + if (state === "active-selected") { + return `${styleText("green", S_CHECKBOX_SELECTED)} ${label}${ + option.hint ? ` ${styleText("dim", `(${option.hint})`)}` : "" + }`; + } + if (state === "submitted") { + return computeLabel(label, (text) => styleText("dim", text)); + } + return `${styleText("dim", S_CHECKBOX_INACTIVE)} ${computeLabel(label, (text) => + styleText("dim", text), + )}`; +} + +export function multiselectWithNavigationFooter( + opts: MultiSelectOptions & NavigationPromptOptions, +): Promise { + const required = opts.required ?? true; + return new MultiSelectPrompt({ + options: opts.options as Array>, + signal: opts.signal, + input: opts.input, + output: opts.output, + initialValues: opts.initialValues, + required, + cursorAt: opts.cursorAt, + validate(selected: Value[] | undefined) { + if (required && (selected === undefined || selected.length === 0)) { + return `Please select at least one option.\n${styleText( + "reset", + styleText( + "dim", + `Press ${styleText(["gray", "bgWhite", "inverse"], " space ")} to select, ${styleText( + "gray", + styleText("bgWhite", styleText("inverse", " enter ")), + )} to submit`, + ), + )}`; + } + return undefined; + }, + render() { + const showGuide = hasGuide(opts); + const wrappedMessage = wrapTextWithPrefix( + opts.output, + opts.message, + showGuide ? `${clackSymbolBar(this.state)} ` : "", + `${clackSymbol(this.state)} `, + ); + const title = `${showGuide ? `${styleText("gray", S_BAR)}\n` : ""}${wrappedMessage}\n`; + const value = this.value ?? []; + const styleOption = (option: PromptOption, active: boolean) => { + if (option.disabled) { + return multiselectOptionRenderer(option, "disabled"); + } + const selected = value.includes(option.value); + if (active && selected) { + return multiselectOptionRenderer(option, "active-selected"); + } + if (selected) { + return multiselectOptionRenderer(option, "selected"); + } + return multiselectOptionRenderer(option, active ? "active" : "inactive"); + }; + + switch (this.state) { + case "submit": { + const submitText = + this.options + .filter(({ value: optionValue }) => value.includes(optionValue)) + .map((option) => multiselectOptionRenderer(option, "submitted")) + .join(styleText("dim", ", ")) || styleText("dim", "none"); + const wrappedSubmitText = wrapTextWithPrefix( + opts.output, + submitText, + showGuide ? `${styleText("gray", S_BAR)} ` : "", + ); + return `${title}${wrappedSubmitText}`; + } + case "cancel": { + const label = this.options + .filter(({ value: optionValue }) => value.includes(optionValue)) + .map((option) => multiselectOptionRenderer(option, "cancelled")) + .join(styleText("dim", ", ")); + if (label.trim() === "") { + return `${title}${styleText("gray", S_BAR)}`; + } + const wrappedLabel = wrapTextWithPrefix( + opts.output, + label, + showGuide ? `${styleText("gray", S_BAR)} ` : "", + ); + return `${title}${wrappedLabel}${showGuide ? `\n${styleText("gray", S_BAR)}` : ""}`; + } + case "error": { + const prefix = showGuide ? `${styleText("yellow", S_BAR)} ` : ""; + const footer = this.error + .split("\n") + .map((line, index) => + index === 0 + ? `${showGuide ? `${styleText("yellow", S_BAR_END)} ` : ""}${styleText( + "yellow", + line, + )}` + : ` ${line}`, + ) + .join("\n"); + const titleLineCount = title.split("\n").length; + const footerLineCount = footer.split("\n").length + 1; + return `${title}${prefix}${limitOptions({ + output: opts.output, + options: this.options, + cursor: this.cursor, + maxItems: opts.maxItems, + columnPadding: prefix.length, + rowPadding: titleLineCount + footerLineCount, + style: styleOption, + }).join(`\n${prefix}`)}\n${footer}\n`; + } + default: { + const prefix = showGuide ? `${styleText("cyan", S_BAR)} ` : ""; + const footerLines = [ + ...navigationFooterLines(showGuide, "cyan", opts.navigation, [ + styleText("dim", "↑/↓ option"), + styleText("dim", "space select"), + ]), + showGuide ? styleText("cyan", S_BAR_END) : "", + ]; + const titleLineCount = title.split("\n").length; + const footerLineCount = footerLines.length + 1; + return `${title}${prefix}${limitOptions({ + output: opts.output, + options: this.options, + cursor: this.cursor, + maxItems: opts.maxItems, + columnPadding: prefix.length, + rowPadding: titleLineCount + footerLineCount, + style: styleOption, + }).join(`\n${prefix}`)}\n${footerLines.join("\n")}\n`; + } + } + }, + }).prompt() as Promise; +} + +export function autocompleteMultiselectWithNavigationFooter( + opts: AutocompleteMultiSelectOptions & NavigationPromptOptions, +): Promise { + const formatOption = ( + option: PromptOption, + active: boolean, + selectedValues: Value[], + focusedValue: Value | undefined, + ) => { + const isSelected = selectedValues.includes(option.value); + const label = getOptionLabel(option); + const hint = + option.hint && focusedValue !== undefined && option.value === focusedValue + ? styleText("dim", ` (${option.hint})`) + : ""; + const checkbox = isSelected + ? styleText("green", S_CHECKBOX_SELECTED) + : styleText("dim", S_CHECKBOX_INACTIVE); + + if (option.disabled) { + return `${styleText("gray", S_CHECKBOX_INACTIVE)} ${styleText( + ["strikethrough", "gray"], + label, + )}`; + } + if (active) { + return `${checkbox} ${label}${hint}`; + } + return `${checkbox} ${styleText("dim", label)}`; + }; + + const prompt = new AutocompletePrompt>({ + options: opts.options as Array>, + multiple: true, + placeholder: opts.placeholder, + filter: adaptOptionFilter(opts.filter) ?? getFilteredOption, + validate: () => { + if (opts.required && prompt.selectedValues.length === 0) { + return "Please select at least one item"; + } + return undefined; + }, + initialValue: opts.initialValues, + signal: opts.signal, + input: opts.input, + output: opts.output, + render() { + const showGuide = hasGuide(opts); + const title = `${showGuide ? `${styleText("gray", S_BAR)}\n` : ""}${clackSymbol( + this.state, + )} ${opts.message}\n`; + const userInput = this.userInput; + const showPlaceholder = userInput === "" && opts.placeholder !== undefined; + const searchText = + this.isNavigating || showPlaceholder + ? styleText("dim", showPlaceholder ? (opts.placeholder ?? "") : userInput) + : this.userInputWithCursor; + const options = this.options; + const matches = + this.filteredOptions.length !== options.length + ? styleText( + "dim", + ` (${this.filteredOptions.length} match${ + this.filteredOptions.length === 1 ? "" : "es" + })`, + ) + : ""; + + switch (this.state) { + case "submit": { + return `${title}${showGuide ? `${styleText("gray", S_BAR)} ` : ""}${styleText( + "dim", + `${this.selectedValues.length} items selected`, + )}`; + } + case "cancel": { + return `${title}${showGuide ? `${styleText("gray", S_BAR)} ` : ""}${styleText( + ["strikethrough", "dim"], + userInput, + )}`; + } + default: { + const barStyle = this.state === "error" ? "yellow" : "cyan"; + const guidePrefix = showGuide ? `${styleText(barStyle, S_BAR)} ` : ""; + const guidePrefixEnd = showGuide ? styleText(barStyle, S_BAR_END) : ""; + const instructions = [ + `${styleText("dim", "↑/↓")} to navigate`, + `${styleText("dim", this.isNavigating ? "Space/Tab:" : "Tab:")} select`, + `${styleText("dim", "Enter:")} confirm`, + `${styleText("dim", "Type:")} to search`, + ]; + const noResults = + this.filteredOptions.length === 0 && userInput + ? [`${guidePrefix}${styleText("yellow", "No matches found")}`] + : []; + const errorMessage = + this.state === "error" ? [`${guidePrefix}${styleText("yellow", this.error)}`] : []; + const headerLines = [ + ...`${title}${showGuide ? styleText(barStyle, S_BAR) : ""}`.split("\n"), + `${guidePrefix}${styleText("dim", "Search:")} ${searchText}${matches}`, + ...noResults, + ...errorMessage, + ]; + const footerLines = [ + `${guidePrefix}${instructions.join(" • ")}`, + ...navigationFooterLines(showGuide, barStyle, opts.navigation), + guidePrefixEnd, + ]; + const displayOptions = limitOptions({ + cursor: this.cursor, + options: this.filteredOptions, + style: (option, active) => + formatOption(option, active, this.selectedValues, this.focusedValue), + maxItems: opts.maxItems, + output: opts.output, + rowPadding: headerLines.length + footerLines.length, + }); + + return [ + ...headerLines, + ...displayOptions.map((option) => `${guidePrefix}${option}`), + ...footerLines, + ].join("\n"); + } + } + }, + }); + + return prompt.prompt() as Promise; +} + +export function confirmWithNavigationFooter( + opts: ConfirmOptions & NavigationPromptOptions, +): Promise { + const active = opts.active ?? "Yes"; + const inactive = opts.inactive ?? "No"; + return new ConfirmPrompt({ + active, + inactive, + signal: opts.signal, + input: opts.input, + output: opts.output, + initialValue: opts.initialValue ?? true, + render() { + const showGuide = hasGuide(opts); + const titlePrefix = `${clackSymbol(this.state)} `; + const titlePrefixBar = showGuide ? `${styleText("gray", S_BAR)} ` : ""; + const messageLines = wrapTextWithPrefix( + opts.output, + opts.message, + titlePrefixBar, + titlePrefix, + ); + const title = `${showGuide ? `${styleText("gray", S_BAR)}\n` : ""}${messageLines}\n`; + const value = this.value ? active : inactive; + + switch (this.state) { + case "submit": { + const submitPrefix = showGuide ? `${styleText("gray", S_BAR)} ` : ""; + return `${title}${submitPrefix}${styleText("dim", value)}`; + } + case "cancel": { + const cancelPrefix = showGuide ? `${styleText("gray", S_BAR)} ` : ""; + return `${title}${cancelPrefix}${styleText(["strikethrough", "dim"], value)}${ + showGuide ? `\n${styleText("gray", S_BAR)}` : "" + }`; + } + default: { + const defaultPrefix = showGuide ? `${styleText("cyan", S_BAR)} ` : ""; + const defaultPrefixEnd = showGuide ? styleText("cyan", S_BAR_END) : ""; + const separator = opts.vertical + ? showGuide + ? `\n${styleText("cyan", S_BAR)} ` + : "\n" + : ` ${styleText("dim", "/")} `; + const footerLines = navigationFooterLines(showGuide, "cyan", opts.navigation, [ + styleText("dim", "↑/↓ option"), + ]); + return `${title}${defaultPrefix}${ + this.value + ? `${styleText("green", S_RADIO_ACTIVE)} ${active}` + : `${styleText("dim", S_RADIO_INACTIVE)} ${styleText("dim", active)}` + }${separator}${ + !this.value + ? `${styleText("green", S_RADIO_ACTIVE)} ${inactive}` + : `${styleText("dim", S_RADIO_INACTIVE)} ${styleText("dim", inactive)}` + }\n${footerLines.length > 0 ? `${footerLines.join("\n")}\n` : ""}${defaultPrefixEnd}\n`; + } + } + }, + }).prompt() as Promise; +} diff --git a/src/wizard/clack-prompter.test.ts b/src/wizard/clack-prompter.test.ts index f029065ed700..745843f16bf2 100644 --- a/src/wizard/clack-prompter.test.ts +++ b/src/wizard/clack-prompter.test.ts @@ -1,9 +1,71 @@ // Clack prompter tests cover prompt rendering, validation, and cancellation. import { afterEach, describe, expect, it, vi } from "vitest"; + +const clackMocks = vi.hoisted(() => ({ + autocomplete: vi.fn(), + autocompleteMultiselect: vi.fn(), + cancel: vi.fn(), + confirm: vi.fn(), + intro: vi.fn(), + isCancel: vi.fn(() => false), + multiselect: vi.fn(), + outro: vi.fn(), + password: vi.fn(), + select: vi.fn(), + settings: { actions: new Set(["left", "right"]) }, + spinner: vi.fn(() => ({ + start: vi.fn(), + message: vi.fn(), + clear: vi.fn(), + stop: vi.fn(), + })), + text: vi.fn(), +})); + +const navigationPromptMocks = vi.hoisted(() => ({ + autocompleteMultiselectWithNavigationFooter: vi.fn(), + autocompleteWithNavigationFooter: vi.fn(), + confirmWithNavigationFooter: vi.fn(), + multiselectWithNavigationFooter: vi.fn(), + passwordWithNavigationFooter: vi.fn(), + selectWithNavigationFooter: vi.fn(), + textWithNavigationFooter: vi.fn(), +})); + +vi.mock("@clack/prompts", () => ({ + autocomplete: clackMocks.autocomplete, + autocompleteMultiselect: clackMocks.autocompleteMultiselect, + cancel: clackMocks.cancel, + confirm: clackMocks.confirm, + intro: clackMocks.intro, + isCancel: clackMocks.isCancel, + multiselect: clackMocks.multiselect, + outro: clackMocks.outro, + password: clackMocks.password, + select: clackMocks.select, + settings: clackMocks.settings, + spinner: clackMocks.spinner, + text: clackMocks.text, +})); + +vi.mock("./clack-navigation-prompts.js", () => ({ + autocompleteMultiselectWithNavigationFooter: + navigationPromptMocks.autocompleteMultiselectWithNavigationFooter, + autocompleteWithNavigationFooter: navigationPromptMocks.autocompleteWithNavigationFooter, + confirmWithNavigationFooter: navigationPromptMocks.confirmWithNavigationFooter, + multiselectWithNavigationFooter: navigationPromptMocks.multiselectWithNavigationFooter, + passwordWithNavigationFooter: navigationPromptMocks.passwordWithNavigationFooter, + selectWithNavigationFooter: navigationPromptMocks.selectWithNavigationFooter, + textWithNavigationFooter: navigationPromptMocks.textWithNavigationFooter, +})); + import { createClackPrompter, tokenizedOptionFilter } from "./clack-prompter.js"; +import { WizardNavigationError } from "./prompts.js"; afterEach(() => { vi.restoreAllMocks(); + vi.clearAllMocks(); + clackMocks.settings.actions = new Set(["left", "right"]); }); describe("tokenizedOptionFilter", () => { @@ -48,4 +110,165 @@ describe("createClackPrompter", () => { expect(write).toHaveBeenCalledWith('{"ok":true}\n'); }); + + it("renders vertical confirms as stacked yes/no select choices", async () => { + clackMocks.select.mockResolvedValue(true); + const prompter = createClackPrompter(); + + await expect( + prompter.confirm({ + message: "Continue?", + layout: "vertical", + }), + ).resolves.toBe(true); + + expect(clackMocks.confirm).not.toHaveBeenCalled(); + expect(clackMocks.select).toHaveBeenCalledWith( + expect.objectContaining({ + options: [ + { value: true, label: "Yes" }, + { value: false, label: "No" }, + ], + initialValue: true, + }), + ); + }); + + it("uses navigation-aware searchable selects when prompt navigation is active", async () => { + navigationPromptMocks.autocompleteWithNavigationFooter.mockResolvedValue("two"); + const prompter = createClackPrompter(); + + await expect( + prompter.select({ + message: "Pick", + options: [ + { value: "one", label: "One" }, + { value: "two", label: "Two" }, + ], + searchable: true, + navigation: { canGoBack: true, canGoForward: false }, + }), + ).resolves.toBe("two"); + + expect(clackMocks.autocomplete).not.toHaveBeenCalled(); + expect(navigationPromptMocks.autocompleteWithNavigationFooter).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Pick"), + navigation: { canGoBack: true, canGoForward: false }, + }), + ); + }); + + it("passes abort signals to navigation-aware confirms", async () => { + navigationPromptMocks.confirmWithNavigationFooter.mockResolvedValue(true); + const prompter = createClackPrompter(); + + await expect( + prompter.confirm({ + message: "Continue?", + layout: "vertical", + navigation: { canGoBack: true, canGoForward: false }, + }), + ).resolves.toBe(true); + + expect(clackMocks.confirm).not.toHaveBeenCalled(); + expect(clackMocks.select).not.toHaveBeenCalled(); + expect(navigationPromptMocks.confirmWithNavigationFooter).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Continue?"), + initialValue: undefined, + vertical: true, + navigation: { canGoBack: true, canGoForward: false }, + signal: expect.any(AbortSignal), + }), + ); + }); + + it("passes abort signals to navigation-aware text prompts", async () => { + navigationPromptMocks.textWithNavigationFooter.mockResolvedValue("workspace"); + const prompter = createClackPrompter(); + + await expect( + prompter.text({ + message: "Workspace", + initialValue: "~/.openclaw/workspace", + placeholder: "path", + navigation: { canGoBack: true, canGoForward: true }, + }), + ).resolves.toBe("workspace"); + + expect(clackMocks.text).not.toHaveBeenCalled(); + expect(navigationPromptMocks.textWithNavigationFooter).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Workspace"), + initialValue: "~/.openclaw/workspace", + placeholder: "path", + navigation: { canGoBack: true, canGoForward: true }, + signal: expect.any(AbortSignal), + }), + ); + }); + + it("rejects navigation immediately when a prompt does not resolve after abort", async () => { + navigationPromptMocks.textWithNavigationFooter.mockImplementation( + async () => await new Promise(() => {}), + ); + const prompter = createClackPrompter(); + + const result = prompter.text({ + message: "Workspace", + navigation: { canGoBack: false, canGoForward: true }, + }); + await Promise.resolve(); + process.stdin.emit("keypress", undefined, { name: "right" }); + + await expect(result).rejects.toMatchObject({ + direction: "forward", + } satisfies Partial); + }); + + it("keeps text cursor actions when prompt navigation has no available move", async () => { + navigationPromptMocks.textWithNavigationFooter.mockImplementation(async () => { + expect(clackMocks.settings.actions.has("left")).toBe(true); + expect(clackMocks.settings.actions.has("right")).toBe(true); + return "workspace"; + }); + const prompter = createClackPrompter(); + + await expect( + prompter.text({ + message: "Workspace", + navigation: { canGoBack: false, canGoForward: false }, + }), + ).resolves.toBe("workspace"); + + expect(navigationPromptMocks.textWithNavigationFooter).toHaveBeenCalledWith( + expect.objectContaining({ + navigation: { canGoBack: false, canGoForward: false }, + signal: undefined, + }), + ); + }); + + it("passes abort signals to navigation-aware password prompts", async () => { + navigationPromptMocks.passwordWithNavigationFooter.mockResolvedValue("secret"); + const prompter = createClackPrompter(); + + await expect( + prompter.text({ + message: "API key", + sensitive: true, + navigation: { canGoBack: true, canGoForward: true }, + }), + ).resolves.toBe("secret"); + + expect(clackMocks.password).not.toHaveBeenCalled(); + expect(navigationPromptMocks.passwordWithNavigationFooter).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("API key"), + navigation: { canGoBack: true, canGoForward: true }, + signal: expect.any(AbortSignal), + }), + ); + }); }); diff --git a/src/wizard/clack-prompter.ts b/src/wizard/clack-prompter.ts index a530d4a47214..7f2964a68e2c 100644 --- a/src/wizard/clack-prompter.ts +++ b/src/wizard/clack-prompter.ts @@ -11,6 +11,7 @@ import { outro, password, select, + settings, spinner, text, } from "@clack/prompts"; @@ -24,8 +25,17 @@ import { } from "../../packages/terminal-core/src/prompt-style.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; import { createCliProgress } from "../cli/progress.js"; -import type { WizardProgress, WizardPrompter } from "./prompts.js"; -import { WizardCancelledError } from "./prompts.js"; +import { + autocompleteMultiselectWithNavigationFooter, + autocompleteWithNavigationFooter, + confirmWithNavigationFooter, + multiselectWithNavigationFooter, + passwordWithNavigationFooter, + selectWithNavigationFooter, + textWithNavigationFooter, +} from "./clack-navigation-prompts.js"; +import type { WizardProgress, WizardPrompter, WizardPromptNavigation } from "./prompts.js"; +import { WizardCancelledError, WizardNavigationError } from "./prompts.js"; // Clack-backed WizardPrompter implementation for interactive CLI setup. It // converts the generic wizard prompt contract into styled Clack prompts. @@ -37,6 +47,88 @@ function guardCancel(value: T | symbol): T { return value; } +type KeypressInfo = { + name?: string; +}; + +function resolveNavigationDirection( + navigation: WizardPromptNavigation | undefined, + key: KeypressInfo | undefined, +): "back" | "forward" | undefined { + if (key?.name === "left" && navigation?.canGoBack) { + return "back"; + } + if (key?.name === "right" && navigation?.canGoForward) { + return "forward"; + } + return undefined; +} + +function hasPromptNavigation(navigation: WizardPromptNavigation | undefined): boolean { + return navigation?.canGoBack === true || navigation?.canGoForward === true; +} + +async function withHorizontalCursorActionsDisabled( + disabled: boolean, + work: () => Promise, +): Promise { + if (!disabled) { + return await work(); + } + + const hadLeft = settings.actions.has("left"); + const hadRight = settings.actions.has("right"); + settings.actions.delete("left"); + settings.actions.delete("right"); + try { + return await work(); + } finally { + if (hadLeft) { + settings.actions.add("left"); + } + if (hadRight) { + settings.actions.add("right"); + } + } +} + +async function runPromptWithNavigation( + navigation: WizardPromptNavigation | undefined, + work: (signal: AbortSignal | undefined) => Promise, +): Promise { + const controller = + navigation?.canGoBack || navigation?.canGoForward ? new AbortController() : undefined; + let rejectNavigation: ((error: Error) => void) | undefined; + const onKeypress = (_input: string | undefined, key: KeypressInfo | undefined) => { + const nextDirection = resolveNavigationDirection(navigation, key); + if (!nextDirection) { + return; + } + rejectNavigation?.(new WizardNavigationError(nextDirection)); + controller?.abort(); + }; + + try { + if (!controller) { + return guardCancel(await work(undefined)); + } + + const navigationPromise = new Promise((_, reject) => { + rejectNavigation = reject; + }); + process.stdin.on("keypress", onKeypress); + const promptPromise = work(controller.signal); + promptPromise.catch(() => { + // Navigation may settle first while Clack is still unwinding its prompt. + }); + return guardCancel(await Promise.race([promptPromise, navigationPromise])); + } finally { + if (controller) { + process.stdin.off("keypress", onKeypress); + } + } +} + function normalizeSearchTokens(search: string): string[] { return normalizeLowercaseStringOrEmpty(search) .split(/\s+/) @@ -83,22 +175,49 @@ export function createClackPrompter(): WizardPrompter { }) as Option<(typeof params.options)[number]["value"]>[]; if (params.searchable) { - return guardCancel( - await autocomplete({ - message: stylePromptMessage(params.message), - options, - initialValue: params.initialValue, - filter: tokenizedOptionFilter, - }), + return await withHorizontalCursorActionsDisabled( + hasPromptNavigation(params.navigation), + async () => + await runPromptWithNavigation(params.navigation, async (signal) => + params.navigation + ? await autocompleteWithNavigationFooter({ + message: stylePromptMessage(params.message), + options, + initialValue: params.initialValue, + filter: tokenizedOptionFilter, + signal, + navigation: params.navigation, + }) + : await autocomplete({ + message: stylePromptMessage(params.message), + options, + initialValue: params.initialValue, + filter: tokenizedOptionFilter, + signal, + }), + ), ); } - return guardCancel( - await select({ - message: stylePromptMessage(params.message), - options, - initialValue: params.initialValue, - }), + return await withHorizontalCursorActionsDisabled( + hasPromptNavigation(params.navigation), + async () => + await runPromptWithNavigation(params.navigation, async (signal) => + params.navigation + ? await selectWithNavigationFooter({ + message: stylePromptMessage(params.message), + options, + initialValue: params.initialValue, + signal, + navigation: params.navigation, + }) + : await select({ + message: stylePromptMessage(params.message), + options, + initialValue: params.initialValue, + signal, + }), + ), ); }, multiselect: async (params) => { @@ -108,49 +227,128 @@ export function createClackPrompter(): WizardPrompter { }) as Option<(typeof params.options)[number]["value"]>[]; if (params.searchable) { - return guardCancel( - await autocompleteMultiselect({ - message: stylePromptMessage(params.message), - options, - initialValues: params.initialValues, - filter: tokenizedOptionFilter, - }), + return await withHorizontalCursorActionsDisabled( + hasPromptNavigation(params.navigation), + async () => + await runPromptWithNavigation(params.navigation, async (signal) => + params.navigation + ? await autocompleteMultiselectWithNavigationFooter({ + message: stylePromptMessage(params.message), + options, + initialValues: params.initialValues, + filter: tokenizedOptionFilter, + signal, + navigation: params.navigation, + }) + : await autocompleteMultiselect({ + message: stylePromptMessage(params.message), + options, + initialValues: params.initialValues, + filter: tokenizedOptionFilter, + signal, + }), + ), ); } - return guardCancel( - await multiselect({ - message: stylePromptMessage(params.message), - options, - initialValues: params.initialValues, - }), + return await withHorizontalCursorActionsDisabled( + hasPromptNavigation(params.navigation), + async () => + await runPromptWithNavigation(params.navigation, async (signal) => + params.navigation + ? await multiselectWithNavigationFooter({ + message: stylePromptMessage(params.message), + options, + initialValues: params.initialValues, + signal, + navigation: params.navigation, + }) + : await multiselect({ + message: stylePromptMessage(params.message), + options, + initialValues: params.initialValues, + signal, + }), + ), ); }, text: async (params) => { const validate = params.validate; if (params.sensitive) { - return guardCancel( - await password({ - message: stylePromptMessage(params.message), - validate: validate ? (value) => validate(value ?? "") : undefined, - }), + return await withHorizontalCursorActionsDisabled( + hasPromptNavigation(params.navigation), + async () => + await runPromptWithNavigation(params.navigation, async (signal) => + params.navigation + ? await passwordWithNavigationFooter({ + message: stylePromptMessage(params.message), + validate: validate ? (value) => validate(value ?? "") : undefined, + navigation: params.navigation, + signal, + }) + : await password({ + message: stylePromptMessage(params.message), + validate: validate ? (value) => validate(value ?? "") : undefined, + signal, + }), + ), ); } - return guardCancel( - await text({ - message: stylePromptMessage(params.message), - initialValue: params.initialValue, - placeholder: params.placeholder, - validate: validate ? (value) => validate(value ?? "") : undefined, - }), + return await withHorizontalCursorActionsDisabled( + hasPromptNavigation(params.navigation), + async () => + await runPromptWithNavigation(params.navigation, async (signal) => + params.navigation + ? await textWithNavigationFooter({ + message: stylePromptMessage(params.message), + initialValue: params.initialValue, + placeholder: params.placeholder, + validate: validate ? (value) => validate(value ?? "") : undefined, + navigation: params.navigation, + signal, + }) + : await text({ + message: stylePromptMessage(params.message), + initialValue: params.initialValue, + placeholder: params.placeholder, + validate: validate ? (value) => validate(value ?? "") : undefined, + signal, + }), + ), ); }, confirm: async (params) => - guardCancel( - await confirm({ - message: stylePromptMessage(params.message), - initialValue: params.initialValue, - }), + await withHorizontalCursorActionsDisabled( + hasPromptNavigation(params.navigation), + async () => + await runPromptWithNavigation(params.navigation, async (signal) => { + const message = stylePromptMessage(params.message); + if (params.navigation) { + return await confirmWithNavigationFooter({ + message, + initialValue: params.initialValue, + vertical: params.layout === "vertical", + navigation: params.navigation, + signal, + }); + } + if (params.layout === "vertical") { + return await select({ + message, + options: [ + { value: true, label: "Yes" }, + { value: false, label: "No" }, + ], + initialValue: params.initialValue ?? true, + signal, + }); + } + return await confirm({ + message, + initialValue: params.initialValue, + signal, + }); + }), ), progress: (label: string): WizardProgress => { const spin = spinner(); diff --git a/src/wizard/i18n/locales/en.ts b/src/wizard/i18n/locales/en.ts index cd6639156f2c..ab17cd29273f 100644 --- a/src/wizard/i18n/locales/en.ts +++ b/src/wizard/i18n/locales/en.ts @@ -130,7 +130,13 @@ export const en = { appliedTitle: "Migration applied", cancelled: "migration cancelled", complete: "Migration complete. Run `openclaw doctor` next.", + continuing: "Migration complete. Continuing setup.", + importFrom: "Import from {source}", + includeCredentials: "Import supported auth credentials too?", previewTitle: "Migration preview", + setupModelSeparately: "Set up a model separately", + setupModelSeparatelyHint: "Configure model authentication without importing another agent", + setupSource: "How do you want to set up this agent?", source: "Migration source", sourceAgentHome: "Source agent home", sourcePathHint: "Enter a source path next", @@ -244,6 +250,8 @@ export const en = { existingConfigTitle: "Existing config detected", flowAdvanced: "Manual setup", flowAdvancedHint: "Choose Gateway port, network exposure, Tailscale, and auth.", + flowKeepModel: "Keep existing model config", + flowKeepModelHint: "Skip model/auth setup and keep the current default model.", flowQuickstart: "QuickStart (recommended)", flowQuickstartHint: "Recommended local setup. Change details later with {command}.", intro: "OpenClaw setup", @@ -898,6 +906,7 @@ export const en = { }, }, search: { + apiKeyRequired: "API key required", chooseProvider: "Choose a provider. Some providers need an API key, and some work key-free.", configured: "configured", configureLaterHint: "Configure later with openclaw configure --section web", @@ -980,6 +989,9 @@ export const en = { reinstall: "Reinstall", rerunInstallDaemon: "Or rerun with: {command}", restart: "Restart", + containerRuntimeTitle: "Container runtime", + containerSystemdUnavailable: + "Systemd user services are not available inside this container. OpenClaw is skipping only the background service install; run the Gateway in the foreground or use your container supervisor.", securityReminder: "Running agents on your computer is risky — harden your setup: https://docs.openclaw.ai/security", secretRefAuthFailed: "Could not resolve {field} SecretRef for setup auth.", @@ -992,6 +1004,9 @@ export const en = { "Linux installs use a systemd user service by default. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.", systemdUnavailable: "Systemd user services are unavailable. Skipping lingering checks and service install.", + sessionGatewayStarting: "Starting session Gateway...", + sessionGatewayStarted: "Session Gateway started.", + sessionGatewayStartFailed: "Session Gateway failed to start.", terminalHatch: "Hatch in Terminal (recommended)", webDocs: "Docs: https://docs.openclaw.ai/tools/web", webSearchAutoDetected: "Web search is available via {provider} (auto-detected).", diff --git a/src/wizard/i18n/locales/zh-CN.ts b/src/wizard/i18n/locales/zh-CN.ts index eba1d8d51fb0..ddca4ebd82df 100644 --- a/src/wizard/i18n/locales/zh-CN.ts +++ b/src/wizard/i18n/locales/zh-CN.ts @@ -129,7 +129,13 @@ export const zh_CN = { appliedTitle: "迁移已应用", cancelled: "迁移已取消", complete: "迁移完成。下一步运行 `openclaw doctor`。", + continuing: "迁移完成。继续设置。", + importFrom: "从 {source} 导入", + includeCredentials: "同时导入支持的认证凭据?", previewTitle: "迁移预览", + setupModelSeparately: "单独设置模型", + setupModelSeparatelyHint: "不导入其他 agent,直接配置模型认证", + setupSource: "你想如何设置这个 agent?", source: "迁移来源", sourceAgentHome: "源 agent home", sourcePathHint: "下一步输入源路径", @@ -241,6 +247,8 @@ export const zh_CN = { existingConfigTitle: "检测到已有配置", flowAdvanced: "手动设置", flowAdvancedHint: "选择 Gateway 端口、网络暴露、Tailscale 和认证方式。", + flowKeepModel: "保留现有模型配置", + flowKeepModelHint: "跳过模型/认证设置,保留当前默认模型。", flowQuickstart: "QuickStart(推荐)", flowQuickstartHint: "推荐的本地设置。之后可用 {command} 修改细节。", intro: "OpenClaw 设置", @@ -869,6 +877,7 @@ export const zh_CN = { }, }, search: { + apiKeyRequired: "需要 API key", chooseProvider: "选择一个提供方。有些提供方需要 API key,有些无需 key。", configured: "已配置", configureLaterHint: "稍后可用 openclaw configure --section web 配置", @@ -946,6 +955,9 @@ export const zh_CN = { reinstall: "重新安装", rerunInstallDaemon: "或重新运行:{command}", restart: "重启", + containerRuntimeTitle: "容器运行环境", + containerSystemdUnavailable: + "此容器内没有 systemd 用户服务。OpenClaw 只会跳过后台服务安装;请以前台方式运行 Gateway,或使用你的容器 supervisor。", securityReminder: "在你的电脑上运行 agent 存在风险,请加固设置:https://docs.openclaw.ai/security", secretRefAuthFailed: "无法解析用于设置认证的 {field} SecretRef。", @@ -957,6 +969,9 @@ export const zh_CN = { systemdLingerReason: "Linux 安装默认使用 systemd 用户服务。没有 lingering 时,systemd 会在用户会话退出/空闲后停止会话并终止 Gateway。", systemdUnavailable: "systemd 用户服务不可用。跳过 lingering 检查和服务安装。", + sessionGatewayStarting: "正在启动本次会话的 Gateway...", + sessionGatewayStarted: "本次会话的 Gateway 已启动。", + sessionGatewayStartFailed: "本次会话的 Gateway 启动失败。", terminalHatch: "在终端中启动(推荐)", webDocs: "文档:https://docs.openclaw.ai/tools/web", webSearchAutoDetected: "Web search 可通过 {provider} 使用(自动检测)。", diff --git a/src/wizard/i18n/locales/zh-TW.ts b/src/wizard/i18n/locales/zh-TW.ts index dca8f02c7d8f..611ba386d104 100644 --- a/src/wizard/i18n/locales/zh-TW.ts +++ b/src/wizard/i18n/locales/zh-TW.ts @@ -129,7 +129,13 @@ export const zh_TW = { appliedTitle: "遷移已套用", cancelled: "遷移已取消", complete: "遷移完成。下一步執行 `openclaw doctor`。", + continuing: "遷移完成。繼續設定。", + importFrom: "從 {source} 匯入", + includeCredentials: "也匯入支援的認證憑證?", previewTitle: "遷移預覽", + setupModelSeparately: "另外設定模型", + setupModelSeparatelyHint: "不匯入其他 agent,直接設定模型認證", + setupSource: "你想如何設定這個 agent?", source: "遷移來源", sourceAgentHome: "來源 agent home", sourcePathHint: "下一步輸入來源路徑", @@ -241,6 +247,8 @@ export const zh_TW = { existingConfigTitle: "偵測到既有設定", flowAdvanced: "手動設定", flowAdvancedHint: "選擇 Gateway 連接埠、網路暴露、Tailscale 和認證方式。", + flowKeepModel: "保留既有模型設定", + flowKeepModelHint: "略過模型/認證設定,保留目前預設模型。", flowQuickstart: "QuickStart(建議)", flowQuickstartHint: "建議的本機設定。之後可用 {command} 修改細節。", intro: "OpenClaw 設定", @@ -870,6 +878,7 @@ export const zh_TW = { }, }, search: { + apiKeyRequired: "需要 API key", chooseProvider: "選擇一個提供方。有些提供方需要 API key,有些無需 key。", configured: "已設定", configureLaterHint: "稍後可用 openclaw configure --section web 設定", @@ -947,6 +956,9 @@ export const zh_TW = { reinstall: "重新安裝", rerunInstallDaemon: "或重新執行:{command}", restart: "重新啟動", + containerRuntimeTitle: "容器執行環境", + containerSystemdUnavailable: + "此容器內沒有 systemd 使用者服務。OpenClaw 只會略過背景服務安裝;請以前景方式執行 Gateway,或使用你的容器 supervisor。", securityReminder: "在你的電腦上執行 agent 存在風險,請加固設定:https://docs.openclaw.ai/security", secretRefAuthFailed: "無法解析用於設定認證的 {field} SecretRef。", @@ -958,6 +970,9 @@ export const zh_TW = { systemdLingerReason: "Linux 安裝預設使用 systemd 使用者服務。沒有 lingering 時,systemd 會在使用者工作階段退出/閒置後停止工作階段並終止 Gateway。", systemdUnavailable: "systemd 使用者服務不可用。略過 lingering 檢查和服務安裝。", + sessionGatewayStarting: "正在啟動本次工作階段的 Gateway...", + sessionGatewayStarted: "本次工作階段的 Gateway 已啟動。", + sessionGatewayStartFailed: "本次工作階段的 Gateway 啟動失敗。", terminalHatch: "在終端機中啟動(建議)", webDocs: "文件:https://docs.openclaw.ai/tools/web", webSearchAutoDetected: "Web search 可透過 {provider} 使用(自動偵測)。", diff --git a/src/wizard/navigation-prompter.test.ts b/src/wizard/navigation-prompter.test.ts new file mode 100644 index 000000000000..fbe875ec7c0b --- /dev/null +++ b/src/wizard/navigation-prompter.test.ts @@ -0,0 +1,190 @@ +// Prompt navigation tests cover setup history replay and forward acceptance. +import { describe, expect, it, vi } from "vitest"; +import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js"; +import { runWizardWithPromptNavigation } from "./navigation-prompter.js"; +import type { WizardPrompter, WizardSelectParams } from "./prompts.js"; +import { WizardNavigationError } from "./prompts.js"; + +function selectOptions(values: string[]) { + return values.map((value) => ({ value, label: value })); +} + +function selectParamsAt(select: ReturnType, index: number): WizardSelectParams { + const params = select.mock.calls[index]?.[0]; + if (!params || typeof params !== "object") { + throw new Error(`missing select call ${index}`); + } + return params as WizardSelectParams; +} + +describe("runWizardWithPromptNavigation", () => { + it("restarts at the previous prompt when back is requested", async () => { + let firstPromptCalls = 0; + let secondPromptCalls = 0; + const select = vi.fn(async (params: WizardSelectParams) => { + if (params.message === "First") { + firstPromptCalls++; + return firstPromptCalls === 1 ? "alpha" : "beta"; + } + if (params.message === "Second") { + secondPromptCalls++; + if (secondPromptCalls === 1) { + throw new WizardNavigationError("back"); + } + return "two"; + } + throw new Error(`unexpected prompt ${params.message}`); + }) as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ select }); + let result: { first: string; second: string } | undefined; + + await runWizardWithPromptNavigation(prompter, async (navigationPrompter) => { + const first = await navigationPrompter.select({ + message: "First", + options: selectOptions(["alpha", "beta"]), + }); + const second = await navigationPrompter.select({ + message: "Second", + options: selectOptions(["one", "two"]), + }); + result = { first, second }; + }); + + expect(result).toEqual({ first: "beta", second: "two" }); + expect(select).toHaveBeenCalledTimes(4); + expect(selectParamsAt(select as ReturnType, 2).navigation).toEqual({ + canGoBack: false, + canGoForward: true, + }); + expect(selectParamsAt(select as ReturnType, 3).navigation).toEqual({ + canGoBack: true, + canGoForward: false, + }); + }); + + it("uses right navigation to accept a remembered prompt answer", async () => { + let thirdPromptCalls = 0; + const note = vi.fn(async () => {}); + const select = vi.fn(async (params: WizardSelectParams) => { + if (params.message === "First") { + return "alpha"; + } + if (params.message === "Second") { + if (params.navigation?.canGoForward) { + throw new WizardNavigationError("forward"); + } + return "two"; + } + if (params.message === "Third") { + thirdPromptCalls++; + if (thirdPromptCalls === 1) { + throw new WizardNavigationError("back"); + } + return "final"; + } + throw new Error(`unexpected prompt ${params.message}`); + }) as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ note, select }); + let result: { first: string; second: string; third: string } | undefined; + + await runWizardWithPromptNavigation(prompter, async (navigationPrompter) => { + const first = await navigationPrompter.select({ + message: "First", + options: selectOptions(["alpha"]), + }); + await navigationPrompter.note("after first"); + const second = await navigationPrompter.select({ + message: "Second", + options: selectOptions(["one", "two"]), + }); + await navigationPrompter.note("after second"); + const third = await navigationPrompter.select({ + message: "Third", + options: selectOptions(["final"]), + }); + result = { first, second, third }; + }); + + expect(result).toEqual({ first: "alpha", second: "two", third: "final" }); + expect(select).toHaveBeenCalledTimes(5); + const secondReplayParams = selectParamsAt(select as ReturnType, 3); + expect(secondReplayParams).toMatchObject({ + message: "Second", + initialValue: "two", + navigation: { canGoBack: true, canGoForward: true }, + }); + expect(note).toHaveBeenCalledTimes(3); + expect(note).toHaveBeenNthCalledWith(1, "after first", undefined); + expect(note).toHaveBeenNthCalledWith(2, "after second", undefined); + expect(note).toHaveBeenNthCalledWith(3, "after second", undefined); + }); + + it("does not cache sensitive text answers for forward navigation", async () => { + let thirdPromptCalls = 0; + const select = vi.fn(async () => { + thirdPromptCalls++; + if (thirdPromptCalls === 1) { + throw new WizardNavigationError("back"); + } + return "done"; + }) as unknown as WizardPrompter["select"]; + const text = vi + .fn() + .mockResolvedValueOnce("secret-one") + .mockResolvedValueOnce("secret-two"); + const prompter = createWizardPrompter({ select, text }); + let result: { secret: string; done: string } | undefined; + + await runWizardWithPromptNavigation(prompter, async (navigationPrompter) => { + const secret = await navigationPrompter.text({ + message: "Secret", + sensitive: true, + }); + const done = await navigationPrompter.select({ + message: "Done", + options: selectOptions(["done"]), + }); + result = { secret, done }; + }); + + expect(result).toEqual({ secret: "secret-two", done: "done" }); + expect(text).toHaveBeenCalledTimes(2); + expect(text.mock.calls[1]?.[0]).toMatchObject({ + message: "Secret", + navigation: { canGoBack: false, canGoForward: false }, + }); + }); + + it("disables back replay for prompts after an irreversible boundary", async () => { + const select = vi.fn(async (params: WizardSelectParams) => { + if (params.message === "First") { + return "alpha"; + } + if (params.message === "Second") { + throw new WizardNavigationError("back"); + } + throw new Error(`unexpected prompt ${params.message}`); + }) as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ select }); + + await expect( + runWizardWithPromptNavigation(prompter, async (navigationPrompter) => { + await navigationPrompter.select({ + message: "First", + options: selectOptions(["alpha"]), + }); + navigationPrompter.disableBackNavigation?.(); + await navigationPrompter.select({ + message: "Second", + options: selectOptions(["two"]), + }); + }), + ).rejects.toMatchObject({ direction: "back" }); + + expect(select).toHaveBeenCalledTimes(2); + expect(selectParamsAt(select as ReturnType, 1).navigation).toEqual({ + canGoBack: false, + canGoForward: false, + }); + }); +}); diff --git a/src/wizard/navigation-prompter.ts b/src/wizard/navigation-prompter.ts new file mode 100644 index 000000000000..1c0115c48e6d --- /dev/null +++ b/src/wizard/navigation-prompter.ts @@ -0,0 +1,274 @@ +// Prompt navigation wrapper for interactive setup history. +import type { + WizardMultiSelectParams, + WizardProgress, + WizardPrompter, + WizardSelectParams, +} from "./prompts.js"; +import { WizardNavigationError } from "./prompts.js"; + +type PromptKind = "select" | "multiselect" | "text" | "confirm"; +type WizardTextParams = Parameters[0]; +type WizardConfirmParams = Parameters[0]; + +type PromptRecord = { + kind: PromptKind; + signature: string; + answer: unknown; + answerKey: string; +}; + +type PromptRequest = { + kind: PromptKind; + params: Params; + signature: string; + cacheAnswer: boolean; + withInitial: (params: Params, answer: unknown) => Params; + call: (params: Params) => Promise; +}; + +function inertProgress(): WizardProgress { + return { + update: () => {}, + stop: () => {}, + }; +} + +function stableKey(value: unknown): string { + if (value === undefined) { + return "undefined"; + } + try { + return JSON.stringify(value); + } catch { + return Object.prototype.toString.call(value); + } +} + +function optionSignature(options: Array<{ value: unknown; label: string }>): string { + return stableKey(options.map((option) => [stableKey(option.value), option.label])); +} + +function buildPromptSignature( + kind: PromptKind, + params: { message: string; options?: Array<{ value: unknown; label: string }>; layout?: string }, +): string { + return stableKey({ + kind, + message: params.message, + options: params.options ? optionSignature(params.options) : undefined, + layout: params.layout, + }); +} + +function applyNavigation( + params: Params, + navigation: { canGoBack: boolean; canGoForward: boolean }, +): Params { + return { + ...params, + navigation, + }; +} + +class WizardPromptNavigator { + private cursor = 0; + private targetIndex: number | undefined; + private restartRequested = false; + private backNavigationDisabled = false; + private records: Array = []; + + constructor(private readonly base: WizardPrompter) {} + + readonly prompter: WizardPrompter = { + intro: async (title) => { + if (!this.shouldSuppressOutput()) { + await this.base.intro(title); + } + }, + outro: async (message) => { + if (!this.shouldSuppressOutput()) { + await this.base.outro(message); + } + }, + note: async (message, title) => { + if (!this.shouldSuppressOutput()) { + await this.base.note(message, title); + } + }, + plain: async (message) => { + if (!this.shouldSuppressOutput()) { + await this.base.plain?.(message); + } + }, + select: async (params: WizardSelectParams) => + await this.prompt>({ + kind: "select", + params, + signature: buildPromptSignature("select", params), + cacheAnswer: true, + withInitial: (nextParams, answer) => ({ + ...nextParams, + initialValue: answer as T, + }), + call: (nextParams) => this.base.select(nextParams), + }), + multiselect: async (params: WizardMultiSelectParams) => + await this.prompt>({ + kind: "multiselect", + params, + signature: buildPromptSignature("multiselect", params), + cacheAnswer: true, + withInitial: (nextParams, answer) => ({ + ...nextParams, + initialValues: Array.isArray(answer) ? (answer as T[]) : nextParams.initialValues, + }), + call: (nextParams) => this.base.multiselect(nextParams), + }), + text: async (params) => + await this.prompt({ + kind: "text", + params, + signature: buildPromptSignature("text", params), + cacheAnswer: params.sensitive !== true, + withInitial: (nextParams, answer) => ({ + ...nextParams, + initialValue: typeof answer === "string" ? answer : nextParams.initialValue, + }), + call: (nextParams) => this.base.text(nextParams), + }), + confirm: async (params) => + await this.prompt({ + kind: "confirm", + params, + signature: buildPromptSignature("confirm", params), + cacheAnswer: true, + withInitial: (nextParams, answer) => ({ + ...nextParams, + initialValue: typeof answer === "boolean" ? answer : nextParams.initialValue, + }), + call: (nextParams) => this.base.confirm(nextParams), + }), + progress: (label) => + this.shouldSuppressOutput() ? inertProgress() : this.base.progress(label), + disableBackNavigation: () => { + this.backNavigationDisabled = true; + this.targetIndex = undefined; + }, + }; + + beginPass() { + this.cursor = 0; + this.restartRequested = false; + } + + hasRestartRequest(): boolean { + return this.restartRequested; + } + + private shouldSuppressOutput(): boolean { + return this.targetIndex !== undefined && this.cursor <= this.targetIndex; + } + + private matchingRecord(index: number, kind: PromptKind, signature: string) { + const record = this.records[index]; + if (!record) { + return undefined; + } + if (record.kind === kind && record.signature === signature) { + return record; + } + this.records.splice(index); + if (this.targetIndex !== undefined && index < this.targetIndex) { + this.targetIndex = undefined; + } + return undefined; + } + + private remember(index: number, request: PromptRequest, answer: unknown) { + if (!request.cacheAnswer) { + this.records[index] = undefined; + this.records.splice(index + 1); + return; + } + + const answerKey = stableKey(answer); + const previous = this.records[index]; + this.records[index] = { + kind: request.kind, + signature: request.signature, + answer, + answerKey, + }; + if (!previous || previous.answerKey !== answerKey || previous.signature !== request.signature) { + this.records.splice(index + 1); + } + } + + private async prompt( + request: PromptRequest, + ): Promise { + const index = this.cursor; + const record = this.matchingRecord(index, request.kind, request.signature); + + if (this.targetIndex !== undefined && index < this.targetIndex && record) { + this.cursor = index + 1; + return record.answer as T; + } + + const paramsWithInitial = record + ? request.withInitial(request.params, record.answer) + : request.params; + const paramsWithNavigation = applyNavigation(paramsWithInitial, { + canGoBack: !this.backNavigationDisabled && index > 0, + canGoForward: record !== undefined, + }); + + try { + const answer = await request.call(paramsWithNavigation); + this.remember(index, request as PromptRequest, answer); + this.cursor = index + 1; + if (this.targetIndex !== undefined && index >= this.targetIndex) { + this.targetIndex = undefined; + } + return answer; + } catch (error) { + if (error instanceof WizardNavigationError) { + if (error.direction === "forward" && record) { + this.cursor = index + 1; + this.targetIndex = undefined; + return record.answer as T; + } + if (error.direction === "back" && !this.backNavigationDisabled && index > 0) { + this.targetIndex = index - 1; + this.restartRequested = true; + } + } + throw error; + } + } +} + +export async function runWizardWithPromptNavigation( + basePrompter: WizardPrompter, + runner: (prompter: WizardPrompter) => Promise, +): Promise { + const navigator = new WizardPromptNavigator(basePrompter); + + while (true) { + navigator.beginPass(); + try { + await runner(navigator.prompter); + return; + } catch (error) { + if ( + error instanceof WizardNavigationError && + error.direction === "back" && + navigator.hasRestartRequest() + ) { + continue; + } + throw error; + } + } +} diff --git a/src/wizard/prompts.ts b/src/wizard/prompts.ts index 8a3ac7a8f220..12041db0c312 100644 --- a/src/wizard/prompts.ts +++ b/src/wizard/prompts.ts @@ -5,11 +5,17 @@ export type WizardSelectOption = { hint?: string; }; +export type WizardPromptNavigation = { + canGoBack?: boolean; + canGoForward?: boolean; +}; + export type WizardSelectParams = { message: string; options: Array>; initialValue?: T; searchable?: boolean; + navigation?: WizardPromptNavigation; }; export type WizardMultiSelectParams = { @@ -17,6 +23,7 @@ export type WizardMultiSelectParams = { options: Array>; initialValues?: T[]; searchable?: boolean; + navigation?: WizardPromptNavigation; }; type WizardTextParams = { @@ -27,11 +34,14 @@ type WizardTextParams = { // Render as a masked input. The entered value is never echoed to the // terminal — keeps secrets out of scrollback, transcripts, and screenshots. sensitive?: boolean; + navigation?: WizardPromptNavigation; }; type WizardConfirmParams = { message: string; initialValue?: boolean; + layout?: "inline" | "vertical"; + navigation?: WizardPromptNavigation; }; export type WizardProgress = { @@ -49,6 +59,7 @@ export type WizardPrompter = { text: (params: WizardTextParams) => Promise; confirm: (params: WizardConfirmParams) => Promise; progress: (label: string) => WizardProgress; + disableBackNavigation?: () => void; }; export class WizardCancelledError extends Error { @@ -57,3 +68,10 @@ export class WizardCancelledError extends Error { this.name = "WizardCancelledError"; } } + +export class WizardNavigationError extends Error { + constructor(readonly direction: "back" | "forward") { + super(`wizard navigate ${direction}`); + this.name = "WizardNavigationError"; + } +} diff --git a/src/wizard/session.ts b/src/wizard/session.ts index 81ba5ab2bd9e..578489b248c3 100644 --- a/src/wizard/session.ts +++ b/src/wizard/session.ts @@ -145,7 +145,7 @@ class WizardSessionPrompter implements WizardPrompter { return value; } - async confirm(params: { message: string; initialValue?: boolean }): Promise { + async confirm(params: Parameters[0]): Promise { const res = await this.prompt({ type: "confirm", message: params.message, diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 3b894c861778..09a5f8141eea 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -67,6 +67,12 @@ const hasAuthProfileForProvider = vi.hoisted(() => }) => boolean >(() => false), ); +const isContainerEnvironment = vi.hoisted(() => vi.fn(() => false)); +const startGatewayServer = vi.hoisted(() => + vi.fn(async () => ({ + close: vi.fn(async () => {}), + })), +); vi.mock("../commands/onboard-helpers.js", () => ({ detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })), @@ -151,6 +157,14 @@ vi.mock("../infra/control-ui-assets.js", () => ({ ensureControlUiAssetsBuilt: vi.fn(async () => ({ ok: true })), })); +vi.mock("../infra/container-environment.js", () => ({ + isContainerEnvironment, +})); + +vi.mock("../gateway/server.js", () => ({ + startGatewayServer, +})); + vi.mock("../../packages/terminal-core/src/restore.js", () => ({ restoreTerminalState, })); @@ -288,11 +302,25 @@ function expectNoteTitleNotCalled( expect(calls.filter((call) => call[1] === title)).toEqual([]); } +async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { + configurable: true, + value: platform, + }); + try { + return await fn(); + } finally { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } +} + describe("finalizeSetupWizard", () => { beforeEach(() => { launchTuiCli.mockClear(); restoreTerminalState.mockClear(); - probeGatewayReachable.mockClear(); + probeGatewayReachable.mockReset(); + probeGatewayReachable.mockResolvedValue({ ok: false, detail: "offline" }); waitForGatewayReachable.mockReset(); waitForGatewayReachable.mockResolvedValue({ ok: true }); setupWizardShellCompletion.mockClear(); @@ -322,6 +350,10 @@ describe("finalizeSetupWizard", () => { listConfiguredWebSearchProviders.mockReturnValue([]); hasAuthProfileForProvider.mockReset(); hasAuthProfileForProvider.mockReturnValue(false); + isContainerEnvironment.mockReset(); + isContainerEnvironment.mockReturnValue(false); + startGatewayServer.mockReset(); + startGatewayServer.mockResolvedValue({ close: vi.fn(async () => {}) }); }); it("resolves gateway password SecretRef for probe but omits auth from TUI hatch", async () => { @@ -396,12 +428,15 @@ describe("finalizeSetupWizard", () => { }; expect(probeParams.url).toBe("ws://127.0.0.1:18789"); expect(probeParams.password).toBe("resolved-gateway-password"); // pragma: allowlist secret - expect(launchTuiCli).toHaveBeenCalledWith({ - local: true, - deliver: false, - message: undefined, - timeoutMs: 300_000, - }); + expect(launchTuiCli).toHaveBeenCalledWith( + { + local: true, + deliver: false, + message: undefined, + timeoutMs: 300_000, + }, + {}, + ); }); it("bounds the bootstrap hatch TUI run timeout", async () => { @@ -441,12 +476,57 @@ describe("finalizeSetupWizard", () => { runtime: createRuntime(), }); - expect(launchTuiCli).toHaveBeenCalledWith({ - local: true, - deliver: false, - message: "Wake up, my friend!", - timeoutMs: 300_000, + expect(launchTuiCli).toHaveBeenCalledWith( + { + local: true, + deliver: false, + message: "Wake up, my friend!", + timeoutMs: 300_000, + }, + {}, + ); + }); + + it("does not resend the bootstrap hatch message on setup reruns", async () => { + vi.spyOn(fs, "access").mockResolvedValueOnce(undefined); + const prompter = buildWizardPrompter({ + confirm: vi.fn(async () => false), }); + + await finalizeSetupWizard({ + flow: "quickstart", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: true, + skipUi: false, + }, + baseConfig: {}, + hadExistingConfig: true, + nextConfig: {}, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(launchTuiCli).toHaveBeenCalledWith( + { + local: true, + deliver: false, + message: undefined, + timeoutMs: 300_000, + }, + {}, + ); }); it("localizes the bootstrap hatch TUI seed message", async () => { @@ -489,12 +569,15 @@ describe("finalizeSetupWizard", () => { runtime: createRuntime(), }); - expect(launchTuiCli).toHaveBeenCalledWith({ - local: true, - deliver: false, - message: "醒醒,我的朋友!", - timeoutMs: 300_000, - }); + expect(launchTuiCli).toHaveBeenCalledWith( + { + local: true, + deliver: false, + message: "醒醒,我的朋友!", + timeoutMs: 300_000, + }, + {}, + ); } finally { if (previousLocale === undefined) { delete process.env.OPENCLAW_LOCALE; @@ -504,6 +587,42 @@ describe("finalizeSetupWizard", () => { } }); + it("prints completion before handing off to the TUI", async () => { + const prompter = createLaterPrompter(); + + await finalizeSetupWizard({ + flow: "quickstart", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: true, + skipUi: false, + }, + baseConfig: {}, + nextConfig: {}, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(prompter.outro).toHaveBeenCalledWith( + "Onboarding complete. Use the dashboard link above to control OpenClaw.", + ); + expect(launchTuiCli).toHaveBeenCalledOnce(); + expect(vi.mocked(prompter.outro).mock.invocationCallOrder[0]).toBeLessThan( + launchTuiCli.mock.invocationCallOrder[0], + ); + }); + it("restores terminal state after failed TUI hatch", async () => { launchTuiCli.mockRejectedValueOnce(new Error("TUI exited with code 1")); const select = vi.fn(async (params: { message: string }) => { @@ -932,6 +1051,134 @@ describe("finalizeSetupWizard", () => { expect(requireMockArg(healthCommand, 0, 1)).toBeTypeOf("object"); }); + it("labels unavailable systemd as container runtime information in containers", async () => { + await withPlatform("linux", async () => { + isSystemdUserServiceAvailable.mockResolvedValue(false); + isContainerEnvironment.mockReturnValue(true); + const prompter = createLaterPrompter(); + + await finalizeSetupWizard(createAdvancedFinalizeArgs({ prompter })); + + expectNoteContains( + prompter, + "Systemd user services are not available inside this container.", + "Container runtime", + ); + expectNoteTitleNotCalled(prompter, "Systemd"); + expect(gatewayServiceInstall).not.toHaveBeenCalled(); + }); + }); + + it("starts a session gateway and launches gateway-backed TUI in containers without systemd", async () => { + await withPlatform("linux", async () => { + isSystemdUserServiceAvailable.mockResolvedValue(false); + isContainerEnvironment.mockReturnValue(true); + waitForGatewayReachable.mockResolvedValue({ ok: true }); + probeGatewayReachable.mockResolvedValue({ ok: true }); + const sessionGateway = { close: vi.fn(async () => {}) }; + startGatewayServer.mockResolvedValueOnce(sessionGateway); + const prompter = createLaterPrompter(); + + await finalizeSetupWizard({ + flow: "quickstart", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: undefined, + skipHealth: false, + skipUi: false, + }, + baseConfig: {}, + nextConfig: { + gateway: { + auth: { + mode: "token", + token: "test-token", + }, + }, + }, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: "test-token", + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(startGatewayServer).toHaveBeenCalledWith( + 18789, + expect.objectContaining({ + bind: "loopback", + auth: expect.objectContaining({ + mode: "token", + token: "test-token", + }), + }), + ); + expect(launchTuiCli).toHaveBeenCalledWith( + { + deliver: false, + message: undefined, + timeoutMs: 300_000, + }, + { gatewayUrl: "ws://127.0.0.1:18789", authSource: "config" }, + ); + expect(sessionGateway.close).toHaveBeenCalledWith({ reason: "onboarding tui exited" }); + }); + }); + + it("closes a session gateway when finalize fails before TUI launch", async () => { + await withPlatform("linux", async () => { + isSystemdUserServiceAvailable.mockResolvedValue(false); + isContainerEnvironment.mockReturnValue(true); + waitForGatewayReachable.mockRejectedValueOnce(new Error("probe failed")); + const sessionGateway = { close: vi.fn(async () => {}) }; + startGatewayServer.mockResolvedValueOnce(sessionGateway); + const prompter = createLaterPrompter(); + + await expect( + finalizeSetupWizard({ + flow: "quickstart", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: undefined, + skipHealth: false, + skipUi: false, + }, + baseConfig: {}, + nextConfig: { + gateway: { + auth: { + mode: "token", + token: "test-token", + }, + }, + }, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: "test-token", + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }), + ).rejects.toThrow("probe failed"); + + expect(launchTuiCli).not.toHaveBeenCalled(); + expect(sessionGateway.close).toHaveBeenCalledWith({ reason: "onboarding finalize exited" }); + }); + }); + it("uses the resolved setup password for health checks", async () => { vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "env-password"); resolveSetupSecretInputString.mockResolvedValueOnce("session-password"); diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index ce92df60e60a..1205088fa202 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -27,9 +27,11 @@ import { resolveControlUiLinks, } from "../commands/onboard-helpers.js"; import type { OnboardOptions } from "../commands/onboard-types.js"; +import type { GatewayAuthConfig } from "../config/types.gateway.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; +import { isContainerEnvironment } from "../infra/container-environment.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -46,6 +48,7 @@ type FinalizeOnboardingOptions = { flow: WizardFlow; opts: OnboardOptions; baseConfig: OpenClawConfig; + hadExistingConfig?: boolean; nextConfig: OpenClawConfig; workspaceDir: string; settings: GatewayWizardSettings; @@ -58,6 +61,77 @@ type OnboardSearchModule = typeof import("../commands/onboard-search.js"); let onboardSearchModulePromise: Promise | undefined; const HATCH_TUI_TIMEOUT_MS = 5 * 60 * 1000; +function buildSessionGatewayAuthOverride(params: { + nextConfig: OpenClawConfig; + settings: GatewayWizardSettings; + resolvedGatewayPassword: string; +}): GatewayAuthConfig | undefined { + if (params.settings.authMode === "token" && params.settings.gatewayToken) { + return { + ...params.nextConfig.gateway?.auth, + mode: "token", + token: params.settings.gatewayToken, + }; + } + if (params.settings.authMode === "password" && params.resolvedGatewayPassword) { + return { + ...params.nextConfig.gateway?.auth, + mode: "password", + password: params.resolvedGatewayPassword, + }; + } + return params.nextConfig.gateway?.auth; +} + +async function startSessionGatewayForOnboarding(params: { + nextConfig: OpenClawConfig; + settings: GatewayWizardSettings; + resolvedGatewayPassword: string; + prompter: WizardPrompter; +}): Promise { + const progress = params.prompter.progress(t("wizard.finalize.sessionGatewayStarting")); + try { + const { startGatewayServer } = await import("../gateway/server.js"); + const server = await startGatewayServer(params.settings.port, { + bind: params.settings.bind, + ...(params.settings.bind === "custom" && params.settings.customBindHost + ? { host: params.settings.customBindHost } + : {}), + auth: buildSessionGatewayAuthOverride({ + nextConfig: params.nextConfig, + settings: params.settings, + resolvedGatewayPassword: params.resolvedGatewayPassword, + }), + tailscale: params.nextConfig.gateway?.tailscale, + }); + progress.stop(t("wizard.finalize.sessionGatewayStarted")); + return server; + } catch (error) { + progress.stop(t("wizard.finalize.sessionGatewayStartFailed")); + await params.prompter.note( + [ + t("wizard.finalize.sessionGatewayStartFailed"), + formatErrorMessage(error), + t("wizard.finalize.startGatewayNow", { + command: formatCliCommand("openclaw gateway run"), + }), + ].join("\n"), + "Gateway", + ); + return undefined; + } +} + +async function closeSessionGatewayForOnboarding(params: { + sessionGateway: import("../gateway/server.js").GatewayServer; + runtime: RuntimeEnv; + reason: string; +}): Promise { + await params.sessionGateway.close({ reason: params.reason }).catch((error: unknown) => { + params.runtime.error(formatErrorMessage(error)); + }); +} + async function showControlUiDashboardNote(params: { prompter: WizardPrompter; settings: GatewayWizardSettings; @@ -122,6 +196,7 @@ export async function finalizeSetupWizard( const suppressGatewayTokenOutput = opts.suppressGatewayTokenOutput === true; let gatewayProbe: { ok: boolean; detail?: string } = { ok: true }; let resolvedGatewayPassword = ""; + let sessionGateway: import("../gateway/server.js").GatewayServer | undefined; const withWizardProgress = async ( label: string, @@ -142,8 +217,17 @@ export async function finalizeSetupWizard( const systemdAvailable = process.platform === "linux" ? await isSystemdUserServiceAvailable() : true; - if (process.platform === "linux" && !systemdAvailable) { - await prompter.note(t("wizard.finalize.systemdUnavailable"), "Systemd"); + const linuxWithoutUserSystemd = process.platform === "linux" && !systemdAvailable; + const containerWithoutUserSystemd = linuxWithoutUserSystemd && isContainerEnvironment(); + if (linuxWithoutUserSystemd) { + await prompter.note( + t( + containerWithoutUserSystemd + ? "wizard.finalize.containerSystemdUnavailable" + : "wizard.finalize.systemdUnavailable", + ), + containerWithoutUserSystemd ? t("wizard.finalize.containerRuntimeTitle") : "Systemd", + ); } if (process.platform === "linux" && systemdAvailable) { @@ -164,7 +248,7 @@ export async function finalizeSetupWizard( let installDaemon: boolean; if (explicitInstallDaemon !== undefined) { installDaemon = explicitInstallDaemon; - } else if (process.platform === "linux" && !systemdAvailable) { + } else if (linuxWithoutUserSystemd) { installDaemon = false; } else if (flow === "quickstart") { installDaemon = true; @@ -175,7 +259,7 @@ export async function finalizeSetupWizard( }); } - if (process.platform === "linux" && !systemdAvailable && installDaemon) { + if (linuxWithoutUserSystemd && installDaemon) { await prompter.note( t("wizard.finalize.systemdInstallSkipped"), t("wizard.finalize.gatewayService"), @@ -322,49 +406,76 @@ export async function finalizeSetupWizard( } } - 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, + if (containerWithoutUserSystemd && !opts.skipUi) { + sessionGateway = await startSessionGatewayForOnboarding({ + nextConfig, + settings, + resolvedGatewayPassword, + prompter, }); - // 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, + } + + try { + 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, + } + : 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}`, + ), + ), ); - } catch (err) { - runtime.error(formatHealthCheckFailure(err)); await prompter.note( [ t("common.docs"), @@ -373,384 +484,357 @@ export async function finalizeSetupWizard( ].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", + ); } - } 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); + 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"), - ); + 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 && !suppressGatewayTokenOutput - ? `${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 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 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 }), + const authedUrl = settings.authMode === "token" && settings.gatewayToken && !suppressGatewayTokenOutput - ? 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; - const 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"), - ); + ? `${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 : "", + }); } - - if (gatewayProbe.ok) { - const tokenNotes = [ - 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"), - }), - suppressGatewayTokenOutput ? undefined : t("wizard.finalize.dashboardTokenMemory"), - t("wizard.finalize.dashboardOpenAnytime", { - command: formatCliCommand("openclaw dashboard --no-open"), - }), - suppressGatewayTokenOutput ? undefined : t("wizard.finalize.dashboardTokenPrompt"), - ].filter(Boolean); - await prompter.note(tokenNotes.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: false }); - try { - await launchTuiCli({ - local: true, - deliver: false, - message: hasBootstrap ? t("wizard.finalize.bootstrapHatchMessage") : undefined, - timeoutMs: HATCH_TUI_TIMEOUT_MS, + const gatewayStatusLine = gatewayProbe.ok + ? t("wizard.finalize.gatewayReachable") + : t("wizard.finalize.gatewayNotDetectedStatus", { + detail: gatewayProbe.detail ? ` (${gatewayProbe.detail})` : "", }); - } finally { - restoreTerminalState("post-setup tui", { resumeStdinIfPaused: false }); + const bootstrapPath = path.join( + resolveUserPath(options.workspaceDir), + DEFAULT_BOOTSTRAP_FILENAME, + ); + const hasBootstrap = await fs + .access(bootstrapPath) + .then(() => true) + .catch(() => false); + const shouldSeedBootstrapHatch = hasBootstrap && options.hadExistingConfig !== true; + + await prompter.note( + [ + t("wizard.finalize.webUiUrl", { url: links.httpUrl }), + settings.authMode === "token" && settings.gatewayToken && !suppressGatewayTokenOutput + ? 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; + const seededInBackground = false; + let launchedTui = false; + const shouldLaunchTui = !opts.skipUi; + + if (shouldLaunchTui) { + if (hasBootstrap) { + await prompter.note( + [ + t("wizard.finalize.workspaceReady"), + t("wizard.finalize.firstTerminalChat"), + t("wizard.finalize.editBootstrap"), + ].join("\n"), + t("wizard.finalize.hatchYourAgent"), + ); } - launchedTui = true; - } else if (hatchChoice === "web") { + + if (gatewayProbe.ok) { + const tokenNotes = [ + 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"), + }), + suppressGatewayTokenOutput ? undefined : t("wizard.finalize.dashboardTokenMemory"), + t("wizard.finalize.dashboardOpenAnytime", { + command: formatCliCommand("openclaw dashboard --no-open"), + }), + suppressGatewayTokenOutput ? undefined : t("wizard.finalize.dashboardTokenPrompt"), + ].filter(Boolean); + await prompter.note(tokenNotes.join("\n"), "Token"); + } + } 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) && + !suppressGatewayTokenOutput && + !shouldLaunchTui; + if (shouldOpenControlUi) { const dashboard = await showControlUiDashboardNote({ prompter, settings, authedUrl, controlUiBasePath, - hintToken: - settings.authMode === "token" && !suppressGatewayTokenOutput - ? settings.gatewayToken - : undefined, + hintToken: settings.gatewayToken, }); controlUiOpened = dashboard.opened; - } 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) && - !suppressGatewayTokenOutput && - hatchChoice === null; - if (shouldOpenControlUi) { - const dashboard = await showControlUiDashboardNote({ - prompter, - settings, - authedUrl, - controlUiBasePath, - hintToken: settings.gatewayToken, - }); - controlUiOpened = dashboard.opened; - } - - 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 agentDir = resolveDefaultAgentDir(nextConfig); - const authProviderId = entry?.authProviderId?.trim(); - const authProviderLabel = authProviderId === "xai" ? "xAI" : authProviderId; - const providerAuthProfileAvailable = authProviderId - ? hasAuthProfileForProvider({ - provider: authProviderId, - agentDir, - }) - : false; - const oauthAuthProfileAvailable = - authProviderId && providerAuthProfileAvailable + 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 agentDir = resolveDefaultAgentDir(nextConfig); + const authProviderId = entry?.authProviderId?.trim(); + const authProviderLabel = authProviderId === "xai" ? "xAI" : authProviderId; + const providerAuthProfileAvailable = authProviderId ? hasAuthProfileForProvider({ provider: authProviderId, agentDir, - type: "oauth", }) : false; - const hasCredential = hasKey || providerAuthProfileAvailable; - const keySource = storedKey - ? t("wizard.finalize.webSearchKeyStored") - : keyConfigured - ? t("wizard.finalize.webSearchKeyRef") - : envAvailable - ? t("wizard.finalize.webSearchKeyEnv", { env: entry?.envVars.join(" / ") ?? "" }) - : oauthAuthProfileAvailable && authProviderLabel - ? t("wizard.finalize.webSearchOAuthProfile", { provider: authProviderLabel }) - : providerAuthProfileAvailable && authProviderLabel - ? t("wizard.finalize.webSearchAuthProfile", { provider: authProviderLabel }) - : 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 && entry.requiresCredential === false) { - // Keyless providers (e.g. Parallel Search (Free), DuckDuckGo, Ollama) need - // no API key — report ready rather than the credential-required warning. - await prompter.note( - [ - t("wizard.finalize.webSearchKeyFree"), - "", - t("wizard.finalize.webSearchProvider", { provider: label }), - t("wizard.finalize.webDocs"), - ].join("\n"), - t("wizard.finalize.webSearchTitle"), - ); - } else if (webSearchEnabled !== false && hasCredential) { - 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 (entry.requiresCredential !== false && !hasCredential) { - 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"), - ); + const oauthAuthProfileAvailable = + authProviderId && providerAuthProfileAvailable + ? hasAuthProfileForProvider({ + provider: authProviderId, + agentDir, + type: "oauth", + }) + : false; + const hasCredential = hasKey || providerAuthProfileAvailable; + const keySource = storedKey + ? t("wizard.finalize.webSearchKeyStored") + : keyConfigured + ? t("wizard.finalize.webSearchKeyRef") + : envAvailable + ? t("wizard.finalize.webSearchKeyEnv", { env: entry?.envVars.join(" / ") ?? "" }) + : oauthAuthProfileAvailable && authProviderLabel + ? t("wizard.finalize.webSearchOAuthProfile", { provider: authProviderLabel }) + : providerAuthProfileAvailable && authProviderLabel + ? t("wizard.finalize.webSearchAuthProfile", { provider: authProviderLabel }) + : 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 && entry.requiresCredential === false) { + // Keyless providers (e.g. Parallel Search (Free), DuckDuckGo, Ollama) need + // no API key — report ready rather than the credential-required warning. + await prompter.note( + [ + t("wizard.finalize.webSearchKeyFree"), + "", + t("wizard.finalize.webSearchProvider", { provider: label }), + t("wizard.finalize.webDocs"), + ].join("\n"), + t("wizard.finalize.webSearchTitle"), + ); + } else if (webSearchEnabled !== false && hasCredential) { + 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 (entry.requiresCredential !== false && !hasCredential) { + 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 { - 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"), + // 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"), + ); + } } - } 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) { + + if (codexNativeSummary) { 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.codexNativeSearchOnly"), 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"), + t("wizard.finalize.codexNativeSearchTitle"), ); } - } - 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"), ); + + if (shouldLaunchTui) { + restoreTerminalState("pre-setup tui", { resumeStdinIfPaused: false }); + try { + await launchTuiCli( + { + ...(gatewayProbe.ok ? {} : { local: true }), + deliver: false, + message: shouldSeedBootstrapHatch + ? t("wizard.finalize.bootstrapHatchMessage") + : undefined, + timeoutMs: HATCH_TUI_TIMEOUT_MS, + }, + gatewayProbe.ok ? { gatewayUrl: links.wsUrl, authSource: "config" } : {}, + ); + } finally { + restoreTerminalState("post-setup tui", { resumeStdinIfPaused: false }); + if (sessionGateway) { + await closeSessionGatewayForOnboarding({ + sessionGateway, + runtime, + reason: "onboarding tui exited", + }); + sessionGateway = undefined; + } + } + launchedTui = true; + } + + return { launchedTui }; + } finally { + if (sessionGateway) { + await closeSessionGatewayForOnboarding({ + sessionGateway, + runtime, + reason: "onboarding finalize exited", + }); + } } - - 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 }; } diff --git a/src/wizard/setup.migration-import.test.ts b/src/wizard/setup.migration-import.test.ts index 5cf214dcbd4e..de550c3df0e7 100644 --- a/src/wizard/setup.migration-import.test.ts +++ b/src/wizard/setup.migration-import.test.ts @@ -3,7 +3,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { inspectSetupMigrationFreshness } from "./setup.migration-import.js"; +import { + inspectSetupMigrationFreshness, + listSetupMigrationOptions, +} from "./setup.migration-import.js"; const tempRoots = new Set(); @@ -37,6 +40,36 @@ describe("setup migration import freshness", () => { expect(result).toEqual({ fresh: true, reasons: [] }); }); + it("allows the first-launch security acknowledgement before import", async () => { + const root = await makeTempRoot(); + const result = await inspectSetupMigrationFreshness({ + baseConfig: { + wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" }, + }, + stateDir: path.join(root, "state"), + workspaceDir: path.join(root, "workspace"), + }); + + expect(result).toEqual({ fresh: true, reasons: [] }); + }); + + it("rejects other wizard config during import freshness checks", async () => { + const root = await makeTempRoot(); + const result = await inspectSetupMigrationFreshness({ + baseConfig: { + wizard: { + securityAcknowledgedAt: "2026-06-30T00:00:00.000Z", + lastRunMode: "local", + }, + }, + stateDir: path.join(root, "state"), + workspaceDir: path.join(root, "workspace"), + }); + + expect(result.fresh).toBe(false); + expect(result.reasons).toEqual(["existing config values are loaded"]); + }); + it("rejects existing config, workspace files, and state", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); @@ -58,3 +91,48 @@ describe("setup migration import freshness", () => { ]); }); }); + +describe("setup migration import options", () => { + it("offers bundled manifest migration providers before plugin activation", async () => { + const options = await listSetupMigrationOptions({ + baseConfig: {}, + detections: [], + }); + + expect(options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ providerId: "codex", label: "Codex" }), + expect.objectContaining({ providerId: "claude", label: "Claude" }), + expect.objectContaining({ providerId: "hermes", label: "Hermes" }), + ]), + ); + }); + + it("offers official installable Codex when bundled plugins are unavailable", async () => { + const previousDisableBundled = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; + const previousDisablePersisted = process.env.OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY; + process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1"; + process.env.OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY = "1"; + try { + const options = await listSetupMigrationOptions({ + baseConfig: {}, + detections: [], + }); + + expect(options).toEqual( + expect.arrayContaining([expect.objectContaining({ providerId: "codex", label: "Codex" })]), + ); + } finally { + if (previousDisableBundled === undefined) { + delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; + } else { + process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = previousDisableBundled; + } + if (previousDisablePersisted === undefined) { + delete process.env.OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY; + } else { + process.env.OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY = previousDisablePersisted; + } + } + }); +}); diff --git a/src/wizard/setup.migration-import.ts b/src/wizard/setup.migration-import.ts index 40cccde2d623..59b4c243641e 100644 --- a/src/wizard/setup.migration-import.ts +++ b/src/wizard/setup.migration-import.ts @@ -2,9 +2,28 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OnboardOptions } from "../commands/onboard-types.js"; +import { + ensureOnboardingPluginInstalled, + type OnboardingPluginInstallEntry, +} from "../commands/onboarding-plugin-install.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; -import type { MigrationProviderPlugin } from "../plugins/types.js"; +import { + listAvailableManifestContractPlugins, + loadManifestContractSnapshot, +} from "../plugins/manifest-contract-eligibility.js"; +import { + getOfficialExternalPluginCatalogManifest, + listOfficialExternalPluginCatalogEntries, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, + resolveOfficialExternalPluginLabel, +} from "../plugins/official-external-plugin-catalog.js"; +import type { + MigrationPlan, + MigrationProviderContext, + MigrationProviderPlugin, +} from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; import { t } from "./i18n/index.js"; @@ -19,7 +38,26 @@ export type SetupMigrationDetection = { message?: string; }; +export type SetupMigrationOption = { + providerId: string; + label: string; + hint?: string; +}; + +type InstallableSetupMigrationProvider = { + providerId: string; + entry: OnboardingPluginInstallEntry; + description?: string; +}; + +type ManifestSetupMigrationProvider = { + providerId: string; + label: string; + description?: string; +}; + const MEANINGFUL_CONFIG_IGNORED_KEYS = new Set(["$schema", "meta"]); +const MEANINGFUL_WIZARD_CONFIG_IGNORED_KEYS = new Set(["securityAcknowledgedAt"]); const MEANINGFUL_WORKSPACE_ENTRIES = [ "AGENTS.md", "SOUL.md", @@ -70,8 +108,23 @@ async function hasDirectoryEntries(candidate: string): Promise { } function hasMeaningfulConfig(config: OpenClawConfig): boolean { - return Object.keys(config as Record).some( - (key) => !MEANINGFUL_CONFIG_IGNORED_KEYS.has(key), + return Object.entries(config as Record).some(([key, value]) => { + if (MEANINGFUL_CONFIG_IGNORED_KEYS.has(key)) { + return false; + } + if (key === "wizard") { + return hasMeaningfulWizardConfig(value); + } + return true; + }); +} + +function hasMeaningfulWizardConfig(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return true; + } + return Object.keys(value as Record).some( + (key) => !MEANINGFUL_WIZARD_CONFIG_IGNORED_KEYS.has(key), ); } @@ -176,58 +229,228 @@ function resolveImportSourceDefault(params: { return params.providerId === "hermes" ? "~/.hermes" : ""; } +function resolveInstallableSetupMigrationProviders(): InstallableSetupMigrationProvider[] { + const providers: InstallableSetupMigrationProvider[] = []; + for (const catalogEntry of listOfficialExternalPluginCatalogEntries()) { + const manifest = getOfficialExternalPluginCatalogManifest(catalogEntry); + const pluginId = resolveOfficialExternalPluginId(catalogEntry); + const install = resolveOfficialExternalPluginInstall(catalogEntry); + if (!pluginId || !install) { + continue; + } + for (const providerId of manifest?.contracts?.migrationProviders ?? []) { + providers.push({ + providerId, + entry: { + pluginId, + label: resolveOfficialExternalPluginLabel(catalogEntry), + install, + trustedSourceLinkedOfficialInstall: true, + }, + ...(catalogEntry.description ? { description: catalogEntry.description } : {}), + }); + } + } + return providers; +} + +function formatMigrationProviderId(providerId: string): string { + return providerId + .split(/[-_]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function resolveManifestMigrationProviderLabel(params: { + providerId: string; + pluginName?: string; +}): string { + const pluginName = params.pluginName?.trim().replace(/\s+Migration$/i, ""); + return pluginName || formatMigrationProviderId(params.providerId) || params.providerId; +} + +function resolveManifestSetupMigrationProviders( + baseConfig: OpenClawConfig, +): ManifestSetupMigrationProvider[] { + const snapshot = loadManifestContractSnapshot({ config: baseConfig }); + return listAvailableManifestContractPlugins({ + snapshot, + contract: "migrationProviders", + config: baseConfig, + }).flatMap((plugin) => + (plugin.contracts?.migrationProviders ?? []).map((providerId) => { + const provider: ManifestSetupMigrationProvider = { + providerId, + label: resolveManifestMigrationProviderLabel({ providerId, pluginName: plugin.name }), + }; + if (plugin.description) { + provider.description = plugin.description; + } + return provider; + }), + ); +} + +export async function listSetupMigrationOptions(params: { + baseConfig: OpenClawConfig; + detections: readonly SetupMigrationDetection[]; +}): Promise { + const { resolvePluginMigrationProviders } = await loadMigrationProviderRuntimeModule(); + const providers = resolvePluginMigrationProviders({ cfg: params.baseConfig }); + const options: SetupMigrationOption[] = []; + const providerIds = new Set(); + const addOption = (option: SetupMigrationOption) => { + if (providerIds.has(option.providerId)) { + return; + } + providerIds.add(option.providerId); + options.push(option); + }; + + for (const detection of params.detections) { + addOption({ + providerId: detection.providerId, + label: detection.label, + ...(detection.source || detection.message + ? { hint: detection.source ?? detection.message } + : {}), + }); + } + for (const provider of providers) { + addOption({ + providerId: provider.id, + label: provider.label, + hint: provider.description ?? t("wizard.migration.sourcePathHint"), + }); + } + for (const provider of resolveManifestSetupMigrationProviders(params.baseConfig)) { + addOption({ + providerId: provider.providerId, + label: provider.label, + hint: provider.description ?? t("wizard.migration.sourcePathHint"), + }); + } + for (const provider of resolveInstallableSetupMigrationProviders()) { + addOption({ + providerId: provider.providerId, + label: provider.entry.label, + hint: provider.description ?? t("wizard.migration.sourcePathHint"), + }); + } + + return options; +} + async function selectSetupMigrationProvider(params: { opts: OnboardOptions; baseConfig: OpenClawConfig; detections: readonly SetupMigrationDetection[]; prompter: WizardPrompter; -}): Promise<{ - provider: MigrationProviderPlugin; - providerId: string; -}> { - const { - ensureStandaloneMigrationProviderRegistryLoaded, - resolvePluginMigrationProvider, - resolvePluginMigrationProviders, - } = await loadMigrationProviderRuntimeModule(); - ensureStandaloneMigrationProviderRegistryLoaded({ cfg: params.baseConfig }); - const providers = resolvePluginMigrationProviders({ cfg: params.baseConfig }); - if (providers.length === 0) { +}): Promise { + const options = await listSetupMigrationOptions({ + baseConfig: params.baseConfig, + detections: params.detections, + }); + if (options.length === 0) { throw new Error("No migration providers found."); } - const providerById = new Map(providers.map((provider) => [provider.id, provider])); const providerId = params.opts.importFrom?.trim() || (await params.prompter.select({ message: t("wizard.migration.source"), - options: [ - ...params.detections.map((detection) => ({ - value: detection.providerId, - label: detection.label, - ...(detection.source || detection.message - ? { hint: detection.source ?? detection.message } - : {}), - })), - ...providers - .filter( - (provider) => - !params.detections.some((detection) => detection.providerId === provider.id), - ) - .map((provider) => ({ - value: provider.id, - label: provider.label, - hint: provider.description ?? t("wizard.migration.sourcePathHint"), - })), - ], - initialValue: params.detections[0]?.providerId ?? providers[0]?.id, + options: options.map((option) => ({ + value: option.providerId, + label: option.label, + ...(option.hint ? { hint: option.hint } : {}), + })), + initialValue: params.detections[0]?.providerId ?? options[0]?.providerId, })); - const provider = - providerById.get(providerId) ?? - resolvePluginMigrationProvider({ providerId, cfg: params.baseConfig }); - if (!provider) { + if (!options.some((option) => option.providerId === providerId)) { throw new Error(`Unknown migration provider "${providerId}".`); } - return { provider, providerId }; + return providerId; +} + +async function resolveSetupMigrationProvider(params: { + providerId: string; + baseConfig: OpenClawConfig; + prompter: WizardPrompter; + runtime: RuntimeEnv; + workspaceDir: string; +}): Promise<{ provider: MigrationProviderPlugin; baseConfig: OpenClawConfig }> { + const { ensureStandaloneMigrationProviderRegistryLoaded, resolvePluginMigrationProvider } = + await loadMigrationProviderRuntimeModule(); + ensureStandaloneMigrationProviderRegistryLoaded({ + cfg: params.baseConfig, + providerId: params.providerId, + }); + const existing = resolvePluginMigrationProvider({ + providerId: params.providerId, + cfg: params.baseConfig, + }); + if (existing) { + return { provider: existing, baseConfig: params.baseConfig }; + } + const installable = resolveInstallableSetupMigrationProviders().find( + (provider) => provider.providerId === params.providerId, + ); + if (!installable) { + throw new Error(`Unknown migration provider "${params.providerId}".`); + } + const result = await ensureOnboardingPluginInstalled({ + cfg: params.baseConfig, + entry: installable.entry, + prompter: params.prompter, + runtime: params.runtime, + workspaceDir: params.workspaceDir, + promptInstall: false, + }); + if (!result.installed) { + throw new Error(`Could not install migration provider "${params.providerId}".`); + } + ensureStandaloneMigrationProviderRegistryLoaded({ + cfg: result.cfg, + providerId: params.providerId, + }); + const provider = resolvePluginMigrationProvider({ + providerId: params.providerId, + cfg: result.cfg, + }); + if (!provider) { + throw new Error(`Installed plugin did not register migration provider "${params.providerId}".`); + } + return { provider, baseConfig: result.cfg }; +} + +function hasCredentialCandidate(plan: MigrationPlan): boolean { + return plan.items.some( + (item) => item.kind === "auth" || item.kind === "secret" || item.sensitive === true, + ); +} + +async function createSetupMigrationPlan(params: { + provider: MigrationProviderPlugin; + ctx: MigrationProviderContext; + importSecrets: boolean; + nonInteractive: boolean; + prompter: WizardPrompter; +}): Promise<{ ctx: MigrationProviderContext; plan: MigrationPlan }> { + let ctx = { ...params.ctx, includeSecrets: params.importSecrets }; + let plan = await params.provider.plan(ctx); + if (params.nonInteractive || params.importSecrets || !hasCredentialCandidate(plan)) { + return { ctx, plan }; + } + const includeSecrets = await params.prompter.confirm({ + message: t("wizard.migration.includeCredentials"), + initialValue: true, + }); + if (!includeSecrets) { + return { ctx, plan }; + } + ctx = { ...ctx, includeSecrets: true }; + plan = await params.provider.plan(ctx); + return { ctx, plan }; } export async function runSetupMigrationImport(params: { @@ -237,6 +460,7 @@ export async function runSetupMigrationImport(params: { prompter: WizardPrompter; runtime: RuntimeEnv; commitConfigFile: (config: OpenClawConfig) => Promise; + continueOnboarding?: boolean; }): Promise { const [ { applyLocalSetupWorkspaceConfig, applySkipBootstrapConfig }, @@ -253,13 +477,64 @@ export async function runSetupMigrationImport(params: { loadConfigPathsModule(), import("../commands/onboard-helpers.js"), ]); - const { provider, providerId } = await selectSetupMigrationProvider({ + const providerId = await selectSetupMigrationProvider({ opts: params.opts, baseConfig: params.baseConfig, detections: params.detections, prompter: params.prompter, }); - const sourceDefault = resolveImportSourceDefault({ providerId, detections: params.detections }); + const workspaceInput = + params.opts.workspace ?? + (params.opts.nonInteractive + ? (params.baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE) + : await params.prompter.text({ + message: t("wizard.migration.targetWorkspace"), + initialValue: + params.baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE, + })); + const workspaceDir = resolveUserPath(workspaceInput.trim() || onboardHelpers.DEFAULT_WORKSPACE); + const stateDir = resolveStateDir(); + assertFreshSetupMigrationTarget( + await inspectSetupMigrationFreshness({ + baseConfig: params.baseConfig, + stateDir, + workspaceDir, + }), + ); + const resolvedProvider = await resolveSetupMigrationProvider({ + providerId, + baseConfig: params.baseConfig, + prompter: params.prompter, + runtime: params.runtime, + workspaceDir, + }); + const migrationLogger = createMigrationLogger(params.runtime); + const selectedDetections = [...params.detections]; + if ( + resolvedProvider.provider.detect && + !selectedDetections.some((detection) => detection.providerId === providerId) + ) { + try { + const detection = await resolvedProvider.provider.detect({ + config: resolvedProvider.baseConfig, + stateDir, + logger: migrationLogger, + }); + if (detection.found) { + selectedDetections.push({ + providerId, + label: detection.label ?? resolvedProvider.provider.label, + ...(detection.source ? { source: detection.source } : {}), + ...(detection.message ? { message: detection.message } : {}), + }); + } + } catch (error) { + migrationLogger.debug?.( + `Migration provider ${providerId} detection failed: ${formatErrorMessage(error)}`, + ); + } + } + const sourceDefault = resolveImportSourceDefault({ providerId, detections: selectedDetections }); const sourceDir = params.opts.importSource?.trim() || sourceDefault || @@ -271,40 +546,24 @@ export async function runSetupMigrationImport(params: { message: t("wizard.migration.sourceAgentHome"), initialValue: providerId === "hermes" ? "~/.hermes" : undefined, })); - const workspaceInput = - params.opts.workspace ?? - (params.opts.nonInteractive - ? (params.baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE) - : await params.prompter.text({ - message: t("wizard.migration.targetWorkspace"), - initialValue: - params.baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE, - })); - const workspaceDir = resolveUserPath(workspaceInput.trim() || onboardHelpers.DEFAULT_WORKSPACE); - let targetConfig = applyLocalSetupWorkspaceConfig(params.baseConfig, workspaceDir); + let targetConfig = applyLocalSetupWorkspaceConfig(resolvedProvider.baseConfig, workspaceDir); if (params.opts.skipBootstrap) { targetConfig = applySkipBootstrapConfig(targetConfig); } - - const stateDir = resolveStateDir(); - // Freshness is checked after workspace selection because the migration target - // can be different from the process cwd/default workspace. - assertFreshSetupMigrationTarget( - await inspectSetupMigrationFreshness({ - baseConfig: params.baseConfig, - stateDir, - workspaceDir, - }), - ); - const ctx = { + const initialCtx = { config: targetConfig, stateDir, source: sourceDir, - includeSecrets: Boolean(params.opts.importSecrets), overwrite: false, - logger: createMigrationLogger(params.runtime), + logger: migrationLogger, }; - const plan = await provider.plan(ctx); + const { ctx, plan } = await createSetupMigrationPlan({ + provider: resolvedProvider.provider, + ctx: initialCtx, + importSecrets: Boolean(params.opts.importSecrets), + nonInteractive: Boolean(params.opts.nonInteractive), + prompter: params.prompter, + }); await params.prompter.note( formatMigrationPreview(plan).join("\n"), t("wizard.migration.previewTitle"), @@ -316,7 +575,7 @@ export async function runSetupMigrationImport(params: { ? true : await params.prompter.confirm({ message: t("wizard.migration.apply"), - initialValue: false, + initialValue: true, }); if (!confirmed) { throw new WizardCancelledError(t("wizard.migration.cancelled")); @@ -337,7 +596,7 @@ export async function runSetupMigrationImport(params: { ...(backupPath ? { backupPath } : {}), reportDir, }; - const result = await provider.apply(applyCtx, plan); + const result = await resolvedProvider.provider.apply(applyCtx, plan); const withReport = { ...result, ...((result.backupPath ?? backupPath) ? { backupPath: result.backupPath ?? backupPath } : {}), @@ -348,5 +607,12 @@ export async function runSetupMigrationImport(params: { formatMigrationResult(withReport).join("\n"), t("wizard.migration.appliedTitle"), ); - await params.prompter.outro(t("wizard.migration.complete")); + if (params.continueOnboarding) { + await params.prompter.note( + t("wizard.migration.continuing"), + t("wizard.migration.appliedTitle"), + ); + } else { + await params.prompter.outro(t("wizard.migration.complete")); + } } diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 5901812da2ee..dc1e852a8179 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js"; import type { PluginCompatibilityNotice } from "../plugins/status.js"; @@ -23,6 +23,7 @@ type PromptDefaultModel = typeof import("../commands/model-picker.js").promptDef type ApplyAuthChoice = typeof import("../commands/auth-choice.js").applyAuthChoice; const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} }))); +const keepCurrentAuthChoice = vi.hoisted(() => "__keep-current" as const); const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn(async () => "skip")); const applyAuthChoice = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config })), @@ -90,10 +91,31 @@ const finalizeSetupWizard = vi.hoisted(() => const listChannelPlugins = vi.hoisted(() => vi.fn(() => [])); const logConfigUpdated = vi.hoisted(() => vi.fn(() => {})); const setupInternalHooks = vi.hoisted(() => vi.fn(async (cfg) => cfg)); +const enableDefaultOnboardingInternalHooks = vi.hoisted(() => + vi.fn((cfg) => ({ + ...cfg, + hooks: { + ...(cfg as { hooks?: Record }).hooks, + internal: { + ...(cfg as { hooks?: { internal?: Record } }).hooks?.internal, + entries: { + ...(cfg as { hooks?: { internal?: { entries?: Record } } }).hooks + ?.internal?.entries, + "session-memory": { enabled: true }, + }, + }, + }, + })), +); const detectSetupMigrationSources = vi.hoisted(() => vi.fn(async () => [])); +const listSetupMigrationOptions = vi.hoisted(() => vi.fn(async () => [])); const runSetupMigrationImport = vi.hoisted(() => vi.fn(async () => {})); -const setupChannels = vi.hoisted(() => vi.fn(async (cfg) => cfg)); +const setupChannels = vi.hoisted(() => + vi.fn( + async (cfg: unknown, _runtime?: unknown, _prompter?: WizardPrompter, _options?: unknown) => cfg, + ), +); const setupSkills = vi.hoisted(() => vi.fn(async (cfg) => cfg)); function providerPluginStub( @@ -214,6 +236,7 @@ vi.mock("../agents/auth-profiles.runtime.js", () => ({ })); vi.mock("../commands/auth-choice-prompt.js", () => ({ + KEEP_CURRENT_AUTH_CHOICE: keepCurrentAuthChoice, promptAuthChoiceGrouped, })); @@ -250,11 +273,13 @@ vi.mock("../commands/health.js", () => ({ })); vi.mock("../commands/onboard-hooks.js", () => ({ + enableDefaultOnboardingInternalHooks, setupInternalHooks, })); vi.mock("./setup.migration-import.js", () => ({ detectSetupMigrationSources, + listSetupMigrationOptions, runSetupMigrationImport, })); @@ -369,6 +394,99 @@ describe("runSetupWizard", () => { return dir; } + beforeEach(() => { + vi.clearAllMocks(); + promptAuthChoiceGrouped.mockReset(); + promptAuthChoiceGrouped.mockResolvedValue("skip"); + applyAuthChoice.mockReset(); + applyAuthChoice.mockImplementation(async (args) => ({ config: args.config })); + setupChannels.mockReset(); + setupChannels.mockImplementation(async (cfg) => cfg); + setupSkills.mockReset(); + setupSkills.mockImplementation(async (cfg) => cfg); + configureGatewayForSetup.mockReset(); + configureGatewayForSetup.mockImplementation(async (args) => ({ + nextConfig: args.nextConfig, + settings: { + port: args.localPort ?? 18789, + bind: "loopback", + authMode: "token", + gatewayToken: "test-token", + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + })); + readConfigFileSnapshot.mockReset(); + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/.openclaw/openclaw.json", + exists: false, + raw: null, + parsed: {}, + resolved: {}, + valid: true, + config: {}, + issues: [], + warnings: [], + legacyIssues: [], + }); + probeGatewayReachable.mockReset(); + probeGatewayReachable.mockResolvedValue({ ok: false }); + resolvePreferredProviderForAuthChoice.mockReset(); + resolvePreferredProviderForAuthChoice.mockResolvedValue("demo-provider"); + resolvePluginProvidersRuntime.mockReset(); + resolvePluginProvidersRuntime.mockReturnValue([]); + resolveManifestProviderAuthChoice.mockReset(); + resolveManifestProviderAuthChoice.mockReturnValue(undefined); + resolvePluginSetupProvider.mockReset(); + resolvePluginSetupProvider.mockReturnValue(undefined); + resolveProviderPluginChoice.mockReset(); + resolveProviderPluginChoice.mockReturnValue(null); + promptDefaultModel.mockReset(); + promptDefaultModel.mockResolvedValue({}); + warnIfModelConfigLooksOff.mockReset(); + warnIfModelConfigLooksOff.mockResolvedValue(undefined); + buildPluginCompatibilitySnapshotNotices.mockReset(); + buildPluginCompatibilitySnapshotNotices.mockReturnValue([]); + }); + + it("exits successfully after the auto-launched TUI returns", async () => { + const caseDir = await makeCaseDir("tui-success-exit-"); + await fs.writeFile(path.join(caseDir, DEFAULT_BOOTSTRAP_FILENAME), ""); + const select = vi.fn(async ({ message }: WizardSelectParams) => { + if (message === "How do you want to hatch your agent?") { + return "tui"; + } + return "__skip__"; + }) as unknown as WizardPrompter["select"]; + const prompter = buildWizardPrompter({ select }); + const runtime = createRuntime({ throwsOnExit: true }); + + await expect( + runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "skip", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: false, + workspace: caseDir, + }, + runtime, + prompter, + ), + ).rejects.toThrow("exit:0"); + + expect(runTui).toHaveBeenCalledWith({ + local: true, + deliver: false, + message: "Wake up, my friend!", + }); + }); + it("skips provider entries without an id during preferred-provider lookup", async () => { setupChannels.mockClear(); readConfigFileSnapshot.mockResolvedValueOnce({ @@ -480,7 +598,8 @@ describe("runSetupWizard", () => { async (_params: WizardSelectParams) => "quickstart", ) as unknown as WizardPrompter["select"]; const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); - const prompter = buildWizardPrompter({ select, multiselect }); + const plain: WizardPrompter["plain"] = vi.fn(async () => {}); + const prompter = buildWizardPrompter({ select, multiselect, plain }); const runtime = createRuntime({ throwsOnExit: true }); createConfigIO.mockClear(); ensureAuthProfileStore.mockClear(); @@ -502,6 +621,7 @@ describe("runSetupWizard", () => { ); expect(createConfigIO).toHaveBeenCalledWith({ pluginValidation: "skip" }); + expect(plain).not.toHaveBeenCalled(); expect(select).not.toHaveBeenCalled(); expect(ensureAuthProfileStore).not.toHaveBeenCalled(); expect(setupChannels).not.toHaveBeenCalled(); @@ -510,7 +630,77 @@ describe("runSetupWizard", () => { expect(runTui).not.toHaveBeenCalled(); }); - it("shows setup duration expectations before the security acknowledgement", async () => { + it("auto-enables the bundled session-memory hook without showing the hooks screen", async () => { + replaceConfigFile.mockClear(); + setupInternalHooks.mockClear(); + enableDefaultOnboardingInternalHooks.mockClear(); + const prompter = buildWizardPrompter({}); + const runtime = createRuntime({ throwsOnExit: true }); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "skip", + installDaemon: false, + skipProviders: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + runtime, + prompter, + ); + + expect(setupInternalHooks).not.toHaveBeenCalled(); + expect(enableDefaultOnboardingInternalHooks).toHaveBeenCalledOnce(); + const finalCallIndex = replaceConfigFile.mock.calls.length - 1; + const replaceParams = requireRecord( + getMockCallArg(replaceConfigFile, finalCallIndex, 0, "final config replacement"), + "final config replacement params", + ); + const nextConfig = requireRecord(replaceParams.nextConfig, "next config"); + const hooks = requireRecord(nextConfig.hooks, "next config hooks"); + const internal = requireRecord(hooks.internal, "next config internal hooks"); + const entries = requireRecord(internal.entries, "next config hook entries"); + expect(entries["session-memory"]).toEqual({ enabled: true }); + }); + + it("does not auto-enable default hooks when skipHooks is set", async () => { + replaceConfigFile.mockClear(); + enableDefaultOnboardingInternalHooks.mockClear(); + const prompter = buildWizardPrompter({}); + const runtime = createRuntime({ throwsOnExit: true }); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "skip", + installDaemon: false, + skipProviders: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + skipHooks: true, + }, + runtime, + prompter, + ); + + expect(enableDefaultOnboardingInternalHooks).not.toHaveBeenCalled(); + const finalCallIndex = replaceConfigFile.mock.calls.length - 1; + const replaceParams = requireRecord( + getMockCallArg(replaceConfigFile, finalCallIndex, 0, "final config replacement"), + "final config replacement params", + ); + expect(requireRecord(replaceParams.nextConfig, "next config").hooks).toBeUndefined(); + }); + + it("persists the first security acknowledgement", async () => { + replaceConfigFile.mockClear(); const note: WizardPrompter["note"] = vi.fn(async () => {}); const confirm = vi.fn(async () => true) as unknown as WizardPrompter["confirm"]; const prompter = buildWizardPrompter({ note, confirm }); @@ -532,10 +722,60 @@ describe("runSetupWizard", () => { ); const calls = getWizardNoteCalls(note); - expect(calls[0]?.[1]).toBe("Setup timeline"); - expect(calls[0]?.[0]).toContain("model/auth, workspace, Gateway, channels"); - expect(calls[0]?.[0]).toContain("openclaw configure"); - expect(calls[1]?.[1]).toBe("Security disclaimer"); + expect(calls[0]?.[1]).toBe("Security disclaimer"); + expect(confirm).toHaveBeenCalledOnce(); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: true, + layout: "vertical", + }), + ); + const replaceParams = requireRecord( + getMockCallArg(replaceConfigFile, 0, 0, "config replacement"), + "config replacement params", + ); + expect( + requireRecord(requireRecord(replaceParams.nextConfig, "next config").wizard, "wizard") + .securityAcknowledgedAt, + ).toEqual(expect.any(String)); + }); + + it("skips the security acknowledgement after it was accepted once", async () => { + readConfigFileSnapshot.mockResolvedValueOnce({ + path: "/tmp/.openclaw/openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + resolved: {}, + valid: true, + config: { wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" } }, + issues: [], + warnings: [], + legacyIssues: [], + }); + const note: WizardPrompter["note"] = vi.fn(async () => {}); + const confirm = vi.fn(async () => true) as unknown as WizardPrompter["confirm"]; + const prompter = buildWizardPrompter({ note, confirm }); + const runtime = createRuntime({ throwsOnExit: true }); + + await runSetupWizard( + { + flow: "quickstart", + authChoice: "skip", + installDaemon: false, + skipProviders: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + runtime, + prompter, + ); + + const titles = getWizardNoteCalls(note).map((call) => call?.[1]); + expect(titles).not.toContain("Security disclaimer"); + expect(confirm).not.toHaveBeenCalled(); }); it("persists skipBootstrap and skips workspace bootstrap creation when requested", async () => { @@ -698,6 +938,76 @@ describe("runSetupWizard", () => { ).rejects.toThrow("auth choice is required"); }); + it("keeps current model auth config when the matching provider keep option is selected", async () => { + promptAuthChoiceGrouped.mockClear(); + applyAuthChoice.mockClear(); + promptDefaultModel.mockClear(); + replaceConfigFile.mockClear(); + readConfigFileSnapshot.mockResolvedValueOnce({ + path: "/tmp/.openclaw/openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + resolved: {}, + valid: true, + config: { + wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5", + }, + }, + }, + }, + issues: [], + warnings: [], + legacyIssues: [], + }); + promptAuthChoiceGrouped.mockResolvedValueOnce(keepCurrentAuthChoice); + const workspaceDir = await makeCaseDir("keep-provider-config-"); + const prompter = buildWizardPrompter(); + const runtime = createRuntime(); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + workspace: workspaceDir, + }, + runtime, + prompter, + ); + + expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce(); + expectRecordFields( + getMockCallArg(promptAuthChoiceGrouped, 0, 0, "auth choice prompt"), + { + includeSkip: true, + allowKeepCurrentProvider: true, + }, + "auth choice prompt params", + ); + expect(applyAuthChoice).not.toHaveBeenCalled(); + expect(promptDefaultModel).not.toHaveBeenCalled(); + const finalCallIndex = replaceConfigFile.mock.calls.length - 1; + const replaceParams = requireRecord( + getMockCallArg(replaceConfigFile, finalCallIndex, 0, "final config replacement"), + "final config replacement params", + ); + const nextConfig = requireRecord(replaceParams.nextConfig, "next config"); + const agents = requireRecord(nextConfig.agents, "next config agents"); + const defaults = requireRecord(agents.defaults, "next config agent defaults"); + const model = requireRecord(defaults.model, "next config default model"); + expect(model.primary).toBe("openai/gpt-5.5"); + }); + async function runTuiHatchTestAndExpectLaunch(params: { writeBootstrapFile: boolean; expectedMessage: string | undefined; @@ -719,22 +1029,24 @@ describe("runSetupWizard", () => { const prompter = buildWizardPrompter({ select }); const runtime = createRuntime({ throwsOnExit: true }); - await runSetupWizard( - { - acceptRisk: true, - flow: "quickstart", - mode: "local", - workspace: workspaceDir, - authChoice: "skip", - skipProviders: true, - skipSkills: true, - skipSearch: true, - skipHealth: true, - installDaemon: false, - }, - runtime, - prompter, - ); + await expect( + runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + mode: "local", + workspace: workspaceDir, + authChoice: "skip", + skipProviders: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + installDaemon: false, + }, + runtime, + prompter, + ), + ).rejects.toThrow("exit:0"); expectRecordFields( getMockCallArg(runTui, 0, 0, "tui launch"), @@ -833,6 +1145,46 @@ describe("runSetupWizard", () => { ); }); + it("disables back navigation before side-effecting channel setup", async () => { + setupChannels.mockImplementationOnce(async (cfg, _runtime, channelPrompter) => { + if (!channelPrompter) { + throw new Error("expected channel setup prompter"); + } + await channelPrompter.select({ + message: "Channel side effect", + options: [{ value: "continue", label: "Continue" }], + }); + return cfg; + }); + const select = vi.fn(async () => "continue") as unknown as WizardPrompter["select"]; + const prompter = buildWizardPrompter({ select }); + const runtime = createRuntime(); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "skip", + installDaemon: false, + skipChannels: false, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + runtime, + prompter, + ); + + expect(setupChannels).toHaveBeenCalledOnce(); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Channel side effect", + navigation: { canGoBack: false, canGoForward: false }, + }), + ); + }); + it("prompts for a model during explicit interactive Ollama setup", async () => { promptDefaultModel.mockClear(); warnIfModelConfigLooksOff.mockClear(); @@ -1080,12 +1432,7 @@ describe("runSetupWizard", () => { }); const note: WizardPrompter["note"] = vi.fn(async () => {}); - const select = vi.fn(async (opts: WizardSelectParams) => { - if (opts.message === "Config handling") { - return "keep"; - } - return "quickstart"; - }) as unknown as WizardPrompter["select"]; + const select = vi.fn(async () => "quickstart") as unknown as WizardPrompter["select"]; const prompter = buildWizardPrompter({ note, select }); const runtime = createRuntime(); @@ -1108,6 +1455,10 @@ describe("runSetupWizard", () => { const calls = getWizardNoteCalls(note); const noteTitles = calls.map((call) => call?.[1]); expect(noteTitles).toContain("Plugin compatibility"); + expect(noteTitles).toContain("Existing config detected"); + expect(select).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Config handling" }), + ); const noteBodies = calls .map((call) => call?.[0]) .filter((body): body is string => typeof body === "string"); @@ -1142,12 +1493,7 @@ describe("runSetupWizard", () => { warnings: [], legacyIssues: [], }); - const select = vi.fn(async (opts: WizardSelectParams) => { - if (opts.message === "Config handling") { - return "keep"; - } - return "quickstart"; - }) as unknown as WizardPrompter["select"]; + const select = vi.fn(async () => "quickstart") as unknown as WizardPrompter["select"]; const prompter = buildWizardPrompter({ select }); const runtime = createRuntime(); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 204ea8f989c7..9bd8b1ebbe91 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -12,9 +12,9 @@ import type { GatewayAuthChoice, OnboardMode, OnboardOptions, - ResetScope, } from "../commands/onboard-types.js"; import { createConfigIO, replaceConfigFile, resolveGatewayPort } from "../config/config.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -26,8 +26,13 @@ import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; import { t } from "./i18n/index.js"; +import { runWizardWithPromptNavigation } from "./navigation-prompter.js"; import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; -import { detectSetupMigrationSources, runSetupMigrationImport } from "./setup.migration-import.js"; +import { + detectSetupMigrationSources, + listSetupMigrationOptions, + runSetupMigrationImport, +} from "./setup.migration-import.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; import { getSecurityConfirmMessage, @@ -36,12 +41,14 @@ import { } from "./setup.security-note.js"; import type { QuickstartGatewayDefaults, WizardFlow } from "./setup.types.js"; -type SetupFlowChoice = WizardFlow | "import"; +type SetupFlowChoice = WizardFlow | "import" | "keep-model" | `import:${string}`; type AuthChoiceModule = typeof import("../commands/auth-choice.js"); type ConfigLoggingModule = typeof import("../config/logging.js"); type ModelPickerModule = typeof import("../commands/model-picker.js"); type OnboardConfigModule = typeof import("../commands/onboard-config.js"); +type KeepCurrentAuthChoice = + typeof import("../commands/auth-choice-prompt.js").KEEP_CURRENT_AUTH_CHOICE; let authChoiceModulePromise: Promise | undefined; let configLoggingModulePromise: Promise | undefined; @@ -205,39 +212,81 @@ async function resolveAuthChoiceModelSelectionPolicy(params: { async function requireRiskAcknowledgement(params: { opts: OnboardOptions; prompter: WizardPrompter; -}) { + config: OpenClawConfig; +}): Promise { + if (params.config.wizard?.securityAcknowledgedAt) { + return params.config; + } if (params.opts.acceptRisk === true) { - return; + return applySecurityAcknowledgement(params.config); } await params.prompter.note(getSecurityNoteMessage(), getSecurityNoteTitle()); const ok = await params.prompter.confirm({ message: getSecurityConfirmMessage(), - initialValue: false, + initialValue: true, + layout: "vertical", }); if (!ok) { throw new WizardCancelledError(t("wizard.setup.riskNotAccepted")); } + return applySecurityAcknowledgement(params.config); +} + +function applySecurityAcknowledgement(config: OpenClawConfig): OpenClawConfig { + if (config.wizard?.securityAcknowledgedAt) { + return config; + } + return { + ...config, + wizard: { + ...config.wizard, + securityAcknowledgedAt: new Date().toISOString(), + }, + }; +} + +function hasConfiguredDefaultModel(config: OpenClawConfig): boolean { + return resolveAgentModelPrimaryValue(config.agents?.defaults?.model) !== undefined; +} + +function isAuthChoiceSelected( + value: AuthChoice | KeepCurrentAuthChoice, + keepCurrentAuthChoice: KeepCurrentAuthChoice | undefined, +): value is AuthChoice { + return keepCurrentAuthChoice === undefined || value !== keepCurrentAuthChoice; +} + +function isSetupImportFlowChoice(flow: SetupFlowChoice): boolean { + return flow === "import" || flow.startsWith("import:"); +} + +function resolveImportProviderFromFlowChoice(flow: SetupFlowChoice): string | undefined { + return flow.startsWith("import:") ? flow.slice("import:".length) : undefined; } export async function runSetupWizard( opts: OnboardOptions, runtimeInput: RuntimeEnv | undefined, prompter: WizardPrompter, +) { + await runWizardWithPromptNavigation( + prompter, + async (navigationPrompter) => await runSetupWizardOnce(opts, runtimeInput, navigationPrompter), + ); +} + +async function runSetupWizardOnce( + opts: OnboardOptions, + runtimeInput: RuntimeEnv | undefined, + prompter: WizardPrompter, ) { let runtime = runtimeInput; runtime ??= defaultRuntime; const onboardHelpers = await import("../commands/onboard-helpers.js"); onboardHelpers.printWizardHeader(runtime); await prompter.intro(t("wizard.setup.intro")); - await prompter.note( - t("wizard.setup.durationNote", { - command: formatCliCommand("openclaw configure"), - }), - t("wizard.setup.durationTitle"), - ); - await requireRiskAcknowledgement({ opts, prompter }); const snapshot = await readSetupConfigFileSnapshot(); let baseConfig: OpenClawConfig = snapshot.valid @@ -245,10 +294,10 @@ export async function runSetupWizard( ? (snapshot.sourceConfig ?? snapshot.config) : {} : {}; + baseConfig = await requireRiskAcknowledgement({ opts, prompter, config: baseConfig }); // Ordinary onboard reruns must preserve existing agents.list / bindings. Only // explicit reset or import flows are allowed to shrink the config — see issue // openclaw#84692. - let configResetPerformed = false; let pendingPluginInstallMigrationBaseConfig: OpenClawConfig | undefined = baseConfig; const writeSetupConfigFile = async ( config: OpenClawConfig, @@ -309,15 +358,22 @@ export async function runSetupWizard( command: formatCliCommand("openclaw configure"), }); const manualHint = t("wizard.setup.flowAdvancedHint"); + const hasExistingModelConfig = hasConfiguredDefaultModel(baseConfig); const migrationDetections = await detectSetupMigrationSources({ config: baseConfig, runtime }); - const firstMigrationDetection = migrationDetections[0]; - const importOption = firstMigrationDetection - ? { - value: "import" as const, - label: `Import from ${firstMigrationDetection.label}`, - ...(firstMigrationDetection.source ? { hint: firstMigrationDetection.source } : {}), - } - : undefined; + const migrationOptions = await listSetupMigrationOptions({ + baseConfig, + detections: migrationDetections, + }); + const importOptions = migrationOptions.map((option) => { + const choice: { value: `import:${string}`; label: string; hint?: string } = { + value: `import:${option.providerId}`, + label: t("wizard.migration.importFrom", { source: option.label }), + }; + if (option.hint) { + choice.hint = option.hint; + } + return choice; + }); const explicitFlowRaw = opts.flow?.trim(); const normalizedExplicitFlow = explicitFlowRaw === "manual" ? "advanced" : explicitFlowRaw; if ( @@ -338,74 +394,68 @@ export async function runSetupWizard( normalizedExplicitFlow === "import" ? normalizedExplicitFlow : undefined; + const keepModelOption = hasExistingModelConfig + ? { + value: "keep-model" as const, + label: t("wizard.setup.flowKeepModel"), + hint: t("wizard.setup.flowKeepModelHint"), + } + : undefined; let flow: SetupFlowChoice = explicitFlow ?? (await prompter.select({ message: t("wizard.setup.setupMode"), options: [ + ...(keepModelOption ? [keepModelOption] : []), { value: "quickstart", label: t("wizard.setup.flowQuickstart"), hint: quickstartHint }, { value: "advanced", label: t("wizard.setup.flowAdvanced"), hint: manualHint }, - ...(importOption ? [importOption] : []), + ...importOptions, ], - initialValue: "quickstart", + initialValue: hasExistingModelConfig ? "keep-model" : "quickstart", })); + let keepExistingModelConfig = flow === "keep-model"; + if (keepExistingModelConfig) { + flow = "quickstart"; + } + if (opts.mode === "remote" && flow === "quickstart") { await prompter.note(t("wizard.setup.quickstartOnlyLocal"), t("wizard.setup.quickstartTitle")); flow = "advanced"; } - if (snapshot.exists) { + if (snapshot.exists && !keepExistingModelConfig) { await prompter.note( onboardHelpers.summarizeExistingConfig(baseConfig), t("wizard.setup.existingConfigTitle"), ); - - const action = await prompter.select({ - message: t("wizard.setup.configHandling"), - options: [ - { value: "keep", label: t("wizard.setup.keepCurrent") }, - { value: "modify", label: t("wizard.setup.modifyCurrent") }, - { value: "reset", label: t("wizard.setup.resetBefore") }, - ], - }); - - if (action === "reset") { - const workspaceDefault = - baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE; - const resetScope = (await prompter.select({ - message: t("wizard.setup.resetScope"), - options: [ - { value: "config", label: t("wizard.setup.resetConfig") }, - { - value: "config+creds+sessions", - label: t("wizard.setup.resetConfigCredsSessions"), - }, - { - value: "full", - label: t("wizard.setup.resetFull"), - }, - ], - })) as ResetScope; - await onboardHelpers.handleReset(resetScope, resolveUserPath(workspaceDefault), runtime); - baseConfig = {}; - pendingPluginInstallMigrationBaseConfig = baseConfig; - configResetPerformed = true; - } } - if (opts.importFrom || flow === "import") { + if (opts.importFrom || isSetupImportFlowChoice(flow)) { + const importFrom = opts.importFrom ?? resolveImportProviderFromFlowChoice(flow); + prompter.disableBackNavigation?.(); await runSetupMigrationImport({ - opts, + opts: { + ...opts, + ...(importFrom ? { importFrom } : {}), + }, baseConfig, detections: migrationDetections, prompter, runtime, commitConfigFile: (cfg) => writeWizardConfigFile(cfg, { allowConfigSizeDrop: true }), + continueOnboarding: true, }); - return; + const migratedSnapshot = await readSetupConfigFileSnapshot(); + if (!migratedSnapshot.valid) { + throw new Error("Migration produced an invalid OpenClaw config. Run `openclaw doctor`."); + } + baseConfig = migratedSnapshot.sourceConfig ?? migratedSnapshot.config; + pendingPluginInstallMigrationBaseConfig = baseConfig; + keepExistingModelConfig ||= hasConfiguredDefaultModel(baseConfig); + flow = "quickstart"; } - const wizardFlow: WizardFlow = flow; + const wizardFlow: WizardFlow = flow === "advanced" ? "advanced" : "quickstart"; const quickstartGateway: QuickstartGatewayDefaults = (() => { const hasExisting = @@ -630,8 +680,9 @@ export async function runSetupWizard( nextConfig = applySkipBootstrapConfig(nextConfig); } nextConfig = onboardHelpers.applyWizardMetadata(nextConfig, { command: "onboard", mode }); + prompter.disableBackNavigation?.(); await writeSetupConfigFile(nextConfig, { - allowConfigSizeDrop: configResetPerformed, + allowConfigSizeDrop: false, }); logConfigUpdated(runtime); await prompter.outro(t("wizard.setup.remoteConfigured")); @@ -656,58 +707,128 @@ export async function runSetupWizard( nextConfig = applySkipBootstrapConfig(nextConfig); } - const authChoiceFromPrompt = opts.authChoice === undefined; - let authChoice: AuthChoice | undefined = opts.authChoice; - let authStore: - | ReturnType<(typeof import("../agents/auth-profiles.runtime.js"))["ensureAuthProfileStore"]> - | undefined; - let promptAuthChoiceGrouped: - | (typeof import("../commands/auth-choice-prompt.js"))["promptAuthChoiceGrouped"] - | undefined; - if (authChoiceFromPrompt) { - const { ensureAuthProfileStore } = await import("../agents/auth-profiles.runtime.js"); - ({ promptAuthChoiceGrouped } = await import("../commands/auth-choice-prompt.js")); - authStore = ensureAuthProfileStore(undefined, { - allowKeychainPrompt: false, - }); - } - while (true) { + if (!keepExistingModelConfig) { + const authChoiceFromPrompt = opts.authChoice === undefined; + let authChoice: AuthChoice | KeepCurrentAuthChoice | undefined = opts.authChoice; + let authStore: + | ReturnType<(typeof import("../agents/auth-profiles.runtime.js"))["ensureAuthProfileStore"]> + | undefined; + let promptAuthChoiceGrouped: + | (typeof import("../commands/auth-choice-prompt.js"))["promptAuthChoiceGrouped"] + | undefined; + let keepCurrentAuthChoice: KeepCurrentAuthChoice | undefined; if (authChoiceFromPrompt) { - authChoice = await promptAuthChoiceGrouped!({ - prompter, - store: authStore!, - includeSkip: true, - config: nextConfig, - workspaceDir, + const { ensureAuthProfileStore } = await import("../agents/auth-profiles.runtime.js"); + const authChoicePromptModule = await import("../commands/auth-choice-prompt.js"); + promptAuthChoiceGrouped = authChoicePromptModule.promptAuthChoiceGrouped; + keepCurrentAuthChoice = authChoicePromptModule.KEEP_CURRENT_AUTH_CHOICE; + authStore = ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, }); } - if (authChoice === undefined) { - throw new WizardCancelledError(t("wizard.setup.authChoiceRequired")); - } + while (true) { + if (authChoiceFromPrompt) { + authChoice = await promptAuthChoiceGrouped!({ + prompter, + store: authStore!, + includeSkip: true, + config: nextConfig, + workspaceDir, + allowKeepCurrentProvider: true, + }); + } + if (authChoice === undefined) { + throw new WizardCancelledError(t("wizard.setup.authChoiceRequired")); + } + if (!isAuthChoiceSelected(authChoice, keepCurrentAuthChoice)) { + break; + } - if (authChoice === "custom-api-key") { - const { promptCustomApiConfig } = await import("../commands/onboard-custom.js"); - const customResult = await promptCustomApiConfig({ + if (authChoice === "custom-api-key") { + const { promptCustomApiConfig } = await import("../commands/onboard-custom.js"); + const customResult = await promptCustomApiConfig({ + prompter, + runtime, + config: nextConfig, + secretInputMode: opts.secretInputMode, + }); + nextConfig = customResult.config; + prompter.disableBackNavigation?.(); + break; + } + if (authChoice === "skip") { + // Explicit skip should stay cold: do not bootstrap auth/profile machinery + // or run model/auth checks when the caller already chose to skip setup. + if (authChoiceFromPrompt) { + const { applyPrimaryModel, promptDefaultModel } = await loadModelPickerModule(); + const modelSelection = await promptDefaultModel({ + config: nextConfig, + prompter, + allowKeep: true, + ignoreAllowlist: true, + includeProviderPluginSetups: false, + loadCatalog: false, + workspaceDir, + runtime, + }); + if (modelSelection.config) { + nextConfig = modelSelection.config; + } + if (modelSelection.model) { + nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); + } + + const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); + await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); + } + break; + } + + const [ + { applyAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff }, + { applyPrimaryModel, promptDefaultModel }, + ] = await Promise.all([loadAuthChoiceModule(), loadModelPickerModule()]); + prompter.disableBackNavigation?.(); + const authResult = await applyAuthChoice({ + authChoice, + config: nextConfig, prompter, runtime, - config: nextConfig, - secretInputMode: opts.secretInputMode, + setDefaultModel: true, + preserveExistingDefaultModel: true, + opts: { + ...opts, + token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined, + }, }); - nextConfig = customResult.config; - break; - } - if (authChoice === "skip") { - // Explicit skip should stay cold: do not bootstrap auth/profile machinery - // or run model/auth checks when the caller already chose to skip setup. - if (authChoiceFromPrompt) { - const { applyPrimaryModel, promptDefaultModel } = await loadModelPickerModule(); + nextConfig = authResult.config; + if (authResult.retrySelection) { + if (authChoiceFromPrompt) { + continue; + } + break; + } + if (authResult.agentModelOverride) { + nextConfig = applyPrimaryModel(nextConfig, authResult.agentModelOverride); + } + + const authChoiceModelSelectionPolicy = await resolveAuthChoiceModelSelectionPolicy({ + authChoice, + config: nextConfig, + workspaceDir, + resolvePreferredProviderForAuthChoice, + }); + const shouldPromptModelSelection = + authChoiceFromPrompt || authChoiceModelSelectionPolicy?.promptWhenAuthChoiceProvided; + if (shouldPromptModelSelection) { const modelSelection = await promptDefaultModel({ config: nextConfig, prompter, - allowKeep: true, + allowKeep: authChoiceModelSelectionPolicy?.allowKeepCurrent ?? true, ignoreAllowlist: true, - includeProviderPluginSetups: false, - loadCatalog: false, + includeProviderPluginSetups: true, + preferredProvider: authChoiceModelSelectionPolicy?.preferredProvider, + browseCatalogOnDemand: true, workspaceDir, runtime, }); @@ -717,70 +838,11 @@ export async function runSetupWizard( if (modelSelection.model) { nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); } - - const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); - await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); } + + await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); break; } - - const [ - { applyAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff }, - { applyPrimaryModel, promptDefaultModel }, - ] = await Promise.all([loadAuthChoiceModule(), loadModelPickerModule()]); - const authResult = await applyAuthChoice({ - authChoice, - config: nextConfig, - prompter, - runtime, - setDefaultModel: true, - preserveExistingDefaultModel: true, - opts: { - ...opts, - token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined, - }, - }); - nextConfig = authResult.config; - if (authResult.retrySelection) { - if (authChoiceFromPrompt) { - continue; - } - break; - } - if (authResult.agentModelOverride) { - nextConfig = applyPrimaryModel(nextConfig, authResult.agentModelOverride); - } - - const authChoiceModelSelectionPolicy = await resolveAuthChoiceModelSelectionPolicy({ - authChoice, - config: nextConfig, - workspaceDir, - resolvePreferredProviderForAuthChoice, - }); - const shouldPromptModelSelection = - authChoiceFromPrompt || authChoiceModelSelectionPolicy?.promptWhenAuthChoiceProvided; - if (shouldPromptModelSelection) { - const modelSelection = await promptDefaultModel({ - config: nextConfig, - prompter, - allowKeep: authChoiceModelSelectionPolicy?.allowKeepCurrent ?? true, - ignoreAllowlist: true, - includeProviderPluginSetups: true, - preferredProvider: authChoiceModelSelectionPolicy?.preferredProvider, - browseCatalogOnDemand: true, - workspaceDir, - runtime, - }); - if (modelSelection.config) { - nextConfig = modelSelection.config; - } - if (modelSelection.model) { - nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); - } - } - - await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); - break; } const { configureGatewayForSetup } = await import("./setup.gateway-config.js"); @@ -797,6 +859,7 @@ export async function runSetupWizard( nextConfig = gateway.nextConfig; const settings = gateway.settings; + prompter.disableBackNavigation?.(); if (opts.skipChannels ?? opts.skipProviders) { await prompter.note(t("wizard.setup.skipChannels"), t("wizard.setup.channelsTitle")); } else { @@ -820,7 +883,7 @@ export async function runSetupWizard( } nextConfig = await writeSetupConfigFile(nextConfig, { - allowConfigSizeDrop: configResetPerformed, + allowConfigSizeDrop: false, }); const { logConfigUpdated } = await loadConfigLoggingModule(); logConfigUpdated(runtime); @@ -843,7 +906,9 @@ export async function runSetupWizard( await prompter.note(t("wizard.setup.skipSkills"), t("wizard.setup.skillsTitle")); } else { const { setupSkills } = await import("../commands/onboard-skills.js"); - nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter); + nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter, { + nodeManager: opts.nodeManager, + }); } // Plugin configuration (sandbox backends, tool plugins, etc.) @@ -864,25 +929,28 @@ export async function runSetupWizard( } if (!opts.skipHooks) { - // Setup hooks (session memory on /new) - const { setupInternalHooks } = await import("../commands/onboard-hooks.js"); - nextConfig = await setupInternalHooks(nextConfig, runtime, prompter); + const { enableDefaultOnboardingInternalHooks } = await import("../commands/onboard-hooks.js"); + nextConfig = enableDefaultOnboardingInternalHooks(nextConfig); } nextConfig = onboardHelpers.applyWizardMetadata(nextConfig, { command: "onboard", mode }); nextConfig = await writeSetupConfigFile(nextConfig, { - allowConfigSizeDrop: configResetPerformed, + allowConfigSizeDrop: false, }); const { finalizeSetupWizard } = await import("./setup.finalize.js"); - await finalizeSetupWizard({ + const finalizeResult = await finalizeSetupWizard({ flow: wizardFlow, opts, baseConfig, + hadExistingConfig: snapshot.exists, nextConfig, workspaceDir, settings, prompter, runtime, }); + if (finalizeResult.launchedTui) { + runtime.exit(0); + } }