Streamline OpenClaw onboarding (#98218)

* feat: streamline onboarding setup flow

* fix: harden onboarding preflight installs

* test: isolate gateway preflight safety env

* fix: keep local gateway probes on loopback

* fix: honor onboarding node manager installs

* docs: align setup onboarding reference

* fix: harden bare gateway probe fallback

* fix: honor env gateway auth in bare TUI probe

* test: isolate wizard TUI hatch mocks
This commit is contained in:
Jason (Json)
2026-06-30 11:22:26 -07:00
committed by GitHub
parent 3f147ae5ca
commit 786abe78df
55 changed files with 5273 additions and 983 deletions
+1 -1
View File
@@ -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.
<Note>
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.
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -247,7 +247,7 @@ openclaw configure
openclaw agents add <name>
```
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.
<Note>
`--json` does not imply non-interactive mode. Use `--non-interactive` for scripts.
+12 -13
View File
@@ -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.
<Note>
`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 <dir>` | 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 <mode>` | Onboarding mode: `local` or `remote`. |
@@ -30,26 +31,24 @@ Initialize the baseline config and agent workspace. With any onboarding flag pre
| `--remote-url <url>` | Remote Gateway WebSocket URL. |
| `--remote-token <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 <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
+1 -1
View File
@@ -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
+1
View File
@@ -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",
},
}
```
+1
View File
@@ -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`.
@@ -359,6 +359,9 @@
"id": "codex",
"label": "Codex"
},
"contracts": {
"migrationProviders": ["codex"]
},
"providers": [
{
"id": "codex",
+68 -58
View File
@@ -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 <choice>", `Auth: ${AUTH_CHOICE_HELP}`)
.option(
"--token-provider <id>",
"Token provider id (non-interactive; used with --auth-choice token)",
)
.option("--token <token>", "Token value (non-interactive; used with --auth-choice token)")
.option(
"--token-profile-id <id>",
"Auth profile id (non-interactive; default: <provider>:manual)",
)
.option("--token-expires-in <duration>", "Optional token expiry duration (e.g. 365d, 12h)")
.option(
"--secret-input-mode <mode>",
"API key persistence mode: plaintext|ref (default: plaintext)",
)
.option("--cloudflare-ai-gateway-account-id <id>", "Cloudflare Account ID")
.option("--cloudflare-ai-gateway-gateway-id <id>", "Cloudflare AI Gateway ID");
for (const providerFlag of ONBOARD_AUTH_FLAGS) {
command.option(providerFlag.cliOption, providerFlag.description);
}
return command
.option("--custom-base-url <url>", "Custom provider base URL")
.option("--custom-api-key <key>", "Custom provider API key (optional)")
.option("--custom-model-id <id>", "Custom provider model ID")
.option("--custom-provider-id <id>", "Custom provider ID (optional; auto-derived by default)")
.option(
"--custom-compatibility <mode>",
"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<string, unknown>,
): Partial<OnboardOptions> {
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 <flow>", "Onboard flow: quickstart|advanced|manual|import")
.option("--mode <mode>", "Onboard mode: local|remote")
.option("--auth-choice <choice>", `Auth: ${AUTH_CHOICE_HELP}`)
.option(
"--token-provider <id>",
"Token provider id (non-interactive; used with --auth-choice token)",
)
.option("--token <token>", "Token value (non-interactive; used with --auth-choice token)")
.option(
"--token-profile-id <id>",
"Auth profile id (non-interactive; default: <provider>:manual)",
)
.option("--token-expires-in <duration>", "Optional token expiry duration (e.g. 365d, 12h)")
.option(
"--secret-input-mode <mode>",
"API key persistence mode: plaintext|ref (default: plaintext)",
)
.option("--cloudflare-ai-gateway-account-id <id>", "Cloudflare Account ID")
.option("--cloudflare-ai-gateway-gateway-id <id>", "Cloudflare AI Gateway ID");
.option("--mode <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 <url>", "Custom provider base URL")
.option("--custom-api-key <key>", "Custom provider API key (optional)")
.option("--custom-model-id <id>", "Custom provider model ID")
.option("--custom-provider-id <id>", "Custom provider ID (optional; auto-derived by default)")
.option(
"--custom-compatibility <mode>",
"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 <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
.option("--gateway-auth <mode>", "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<string, unknown>,
);
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<string, unknown>),
gatewayPort: gatewayPort ?? undefined,
gatewayBind: opts.gatewayBind as GatewayBind | undefined,
gatewayAuth: opts.gatewayAuth as GatewayAuthChoice | undefined,
+110 -2
View File
@@ -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"]);
+123 -38
View File
@@ -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 <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 <mode>", "Onboard mode: local|remote")
.option("--flow <flow>", "Onboard flow: quickstart|advanced|manual|import")
.option("--mode <mode>", "Onboard mode: local|remote");
registerOnboardAuthOptions(command);
command
.option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
.option("--gateway-auth <mode>", "Gateway auth: token|password")
.option("--gateway-token <token>", "Gateway token (token auth)")
.option(
"--gateway-token-ref-env <name>",
"Gateway token SecretRef env var name (token auth; e.g. OPENCLAW_GATEWAY_TOKEN)",
)
.option("--gateway-password <password>", "Gateway password (password auth)")
.option("--tailscale <mode>", "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 <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 <name>", "Node manager for skills: npm|pnpm|bun")
.option("--import-from <provider>", "Migration provider to run during onboarding")
.option("--import-source <path>", "Source agent home for --import-from")
.option("--import-secrets", "Import supported secrets during onboarding migration", false)
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
.option("--remote-token <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<string, unknown>),
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,
);
});
});
}
+271 -9
View File
@@ -77,6 +77,18 @@ const setupWizardCommandMock = vi.hoisted(() => vi.fn(async () => {}));
const runCrestodianMock = vi.hoisted(() =>
vi.fn<(options?: unknown) => Promise<void>>(async () => {}),
);
const launchTuiCliMock = vi.hoisted(() =>
vi.fn<(opts: unknown, launchOptions?: unknown) => Promise<void>>(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<void>;
@@ -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<typeof import("../utils.js")>()),
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],
);
});
+256 -4
View File
@@ -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<BareRootLaunchTarget | null> {
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<BareRootLaunchTarget> {
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<ReachableGateway | null> {
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<GatewayProbeAuth> {
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<GatewayProbeTarget[]> {
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<string | null> {
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<string | undefined> {
try {
const { resolveSetupSecretInputString } = await import("../wizard/setup.secret-input.js");
return await resolveSetupSecretInputString(params);
} catch {
return undefined;
}
}
async function resolveLocalGatewayWebSocketUrls(config: OpenClawConfig): Promise<string[]> {
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.',
@@ -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[];
};
+3
View File
@@ -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);
});
+6
View File
@@ -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],
});
}
+179
View File
@@ -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<unknown>) => Promise<unknown>,
): 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>): 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",
]);
});
});
+83 -19
View File
@@ -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<AuthChoice> {
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<PromptAuthChoiceResult>;
export function promptAuthChoiceGrouped(params: PromptAuthChoiceGroupedParams): Promise<AuthChoice>;
export async function promptAuthChoiceGrouped(
params: PromptAuthChoiceGroupedParams,
): Promise<PromptAuthChoiceResult> {
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<AuthChoiceOrBack> => {
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<KeepCurrentAuthChoice>)
: 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<AuthChoiceOrBack> => {
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<AuthChoice> => {
const runFlat = async (): Promise<PromptAuthChoiceResult> => {
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 });
+57 -1
View File
@@ -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),
+37
View File
@@ -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,
@@ -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<string, { enabled?: boolean }> } };
}>();
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<string, { enabled?: boolean }> } };
}>();
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);
@@ -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({
@@ -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,
+119 -7
View File
@@ -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<typeof createBundledSkill>[0],
): ReturnType<typeof createBundledSkill> {
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();
});
});
+54 -78
View File
@@ -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<OpenClawConfig> {
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;
}
+2
View File
@@ -39,6 +39,8 @@ export const FIELD_HELP: Record<string, string> = {
"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":
+1
View File
@@ -22,6 +22,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",
+2
View File
@@ -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;
+1
View File
@@ -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(),
+36
View File
@@ -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" }),
+10 -8
View File
@@ -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") {
+81 -1
View File
@@ -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"),
+58 -6
View File
@@ -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<OpenClawConfig> {
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({
@@ -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;
+15 -1
View File
@@ -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", () => {
+4 -1
View File
@@ -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);
}
};
+20
View File
@@ -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({
+2 -2
View File
@@ -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) => {
@@ -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("");
});
});
+817
View File
@@ -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: Value;
label?: string;
hint?: string;
disabled?: boolean;
};
type NavigationPromptOptions = {
navigation?: WizardPromptNavigation;
withGuide?: boolean;
output?: Writable;
};
function getOptionLabel<Value>(option: PromptOption<Value>): 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<Value>(searchText: string, option: PromptOption<Value>): 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<Value>(
values: Value[],
options: Array<PromptOption<Value>>,
): Array<PromptOption<Value>> {
return options.filter((option) => values.includes(option.value));
}
function adaptOptionFilter<Value>(
filter: AutocompleteOptions<Value>["filter"] | undefined,
): ((search: string, option: PromptOption<Value>) => 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<Value>(option: PromptOption<Value>, 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<Value>(
opts: SelectOptions<Value> & NavigationPromptOptions,
): Promise<Value | symbol> {
return new SelectPrompt({
options: opts.options as Array<PromptOption<Value>>,
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<Value | symbol>;
}
export function autocompleteWithNavigationFooter<Value>(
opts: AutocompleteOptions<Value> & NavigationPromptOptions,
): Promise<Value | symbol> {
const prompt = new AutocompletePrompt<PromptOption<Value>>({
options: opts.options as Array<PromptOption<Value>>,
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<Value>,
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<Value | symbol>;
}
export function textWithNavigationFooter(
opts: TextOptions & NavigationPromptOptions,
): Promise<string | symbol> {
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<string | symbol>;
}
export function passwordWithNavigationFooter(
opts: PasswordOptions & NavigationPromptOptions,
): Promise<string | symbol> {
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<string | symbol>;
}
function multiselectOptionRenderer<Value>(
option: PromptOption<Value>,
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<Value>(
opts: MultiSelectOptions<Value> & NavigationPromptOptions,
): Promise<Value[] | symbol> {
const required = opts.required ?? true;
return new MultiSelectPrompt({
options: opts.options as Array<PromptOption<Value>>,
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<Value>, 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<Value[] | symbol>;
}
export function autocompleteMultiselectWithNavigationFooter<Value>(
opts: AutocompleteMultiSelectOptions<Value> & NavigationPromptOptions,
): Promise<Value[] | symbol> {
const formatOption = (
option: PromptOption<Value>,
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<PromptOption<Value>>({
options: opts.options as Array<PromptOption<Value>>,
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<Value[] | symbol>;
}
export function confirmWithNavigationFooter(
opts: ConfirmOptions & NavigationPromptOptions,
): Promise<boolean | symbol> {
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<boolean | symbol>;
}
+223
View File
@@ -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<string>(() => {}),
);
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<WizardNavigationError>);
});
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),
}),
);
});
});
+243 -45
View File
@@ -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<T>(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<T>(
disabled: boolean,
work: () => Promise<T>,
): Promise<T> {
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<T>(
navigation: WizardPromptNavigation | undefined,
work: (signal: AbortSignal | undefined) => Promise<T | symbol>,
): Promise<T> {
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<T | symbol>((_, 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();
+15
View File
@@ -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).",
+15
View File
@@ -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} 使用(自动检测)。",
+15
View File
@@ -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} 使用(自動偵測)。",
+190
View File
@@ -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<typeof vi.fn>, 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<typeof vi.fn>, 2).navigation).toEqual({
canGoBack: false,
canGoForward: true,
});
expect(selectParamsAt(select as ReturnType<typeof vi.fn>, 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<typeof vi.fn>, 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<WizardPrompter["text"]>()
.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<typeof vi.fn>, 1).navigation).toEqual({
canGoBack: false,
canGoForward: false,
});
});
});
+274
View File
@@ -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<WizardPrompter["text"]>[0];
type WizardConfirmParams = Parameters<WizardPrompter["confirm"]>[0];
type PromptRecord = {
kind: PromptKind;
signature: string;
answer: unknown;
answerKey: string;
};
type PromptRequest<T, Params> = {
kind: PromptKind;
params: Params;
signature: string;
cacheAnswer: boolean;
withInitial: (params: Params, answer: unknown) => Params;
call: (params: Params) => Promise<T>;
};
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 extends { navigation?: unknown }>(
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<PromptRecord | undefined> = [];
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 <T>(params: WizardSelectParams<T>) =>
await this.prompt<T, WizardSelectParams<T>>({
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 <T>(params: WizardMultiSelectParams<T>) =>
await this.prompt<T[], WizardMultiSelectParams<T>>({
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<string, WizardTextParams>({
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<boolean, WizardConfirmParams>({
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<unknown, unknown>, 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<T, Params extends { navigation?: unknown }>(
request: PromptRequest<T, Params>,
): Promise<T> {
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<unknown, unknown>, 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<void>,
): Promise<void> {
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;
}
}
}
+18
View File
@@ -5,11 +5,17 @@ export type WizardSelectOption<T = string> = {
hint?: string;
};
export type WizardPromptNavigation = {
canGoBack?: boolean;
canGoForward?: boolean;
};
export type WizardSelectParams<T = string> = {
message: string;
options: Array<WizardSelectOption<T>>;
initialValue?: T;
searchable?: boolean;
navigation?: WizardPromptNavigation;
};
export type WizardMultiSelectParams<T = string> = {
@@ -17,6 +23,7 @@ export type WizardMultiSelectParams<T = string> = {
options: Array<WizardSelectOption<T>>;
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<string>;
confirm: (params: WizardConfirmParams) => Promise<boolean>;
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";
}
}
+1 -1
View File
@@ -145,7 +145,7 @@ class WizardSessionPrompter implements WizardPrompter {
return value;
}
async confirm(params: { message: string; initialValue?: boolean }): Promise<boolean> {
async confirm(params: Parameters<WizardPrompter["confirm"]>[0]): Promise<boolean> {
const res = await this.prompt({
type: "confirm",
message: params.message,
+265 -18
View File
@@ -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<T>(platform: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
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");
+470 -386
View File
@@ -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<OnboardSearchModule> | 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<import("../gateway/server.js").GatewayServer | undefined> {
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<void> {
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 <T>(
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 };
}
+79 -1
View File
@@ -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<string>();
@@ -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;
}
}
});
});
+338 -72
View File
@@ -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<boolean> {
}
function hasMeaningfulConfig(config: OpenClawConfig): boolean {
return Object.keys(config as Record<string, unknown>).some(
(key) => !MEANINGFUL_CONFIG_IGNORED_KEYS.has(key),
return Object.entries(config as Record<string, unknown>).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<string, unknown>).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<SetupMigrationOption[]> {
const { resolvePluginMigrationProviders } = await loadMigrationProviderRuntimeModule();
const providers = resolvePluginMigrationProviders({ cfg: params.baseConfig });
const options: SetupMigrationOption[] = [];
const providerIds = new Set<string>();
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<string> {
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<OpenClawConfig>;
continueOnboarding?: boolean;
}): Promise<void> {
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"));
}
}
+382 -36
View File
@@ -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<ApplyAuthChoice>(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<string, unknown> }).hooks,
internal: {
...(cfg as { hooks?: { internal?: Record<string, unknown> } }).hooks?.internal,
entries: {
...(cfg as { hooks?: { internal?: { entries?: Record<string, unknown> } } }).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<unknown>) => {
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<unknown>) => "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<unknown>) => {
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<unknown>) => {
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();
+239 -171
View File
@@ -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<AuthChoiceModule> | undefined;
let configLoggingModulePromise: Promise<ConfigLoggingModule> | undefined;
@@ -205,39 +212,81 @@ async function resolveAuthChoiceModelSelectionPolicy(params: {
async function requireRiskAcknowledgement(params: {
opts: OnboardOptions;
prompter: WizardPrompter;
}) {
config: OpenClawConfig;
}): Promise<OpenClawConfig> {
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);
}
}