mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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} 使用(自动检测)。",
|
||||
|
||||
@@ -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} 使用(自動偵測)。",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user