From bfc674876dfc60155a1442a1d38b61eea42d6aff Mon Sep 17 00:00:00 2001 From: MrBrain <176294248+GaosCode@users.noreply.github.com> Date: Mon, 11 May 2026 16:58:15 +0800 Subject: [PATCH] feat(wizard): localize onboarding flows --- src/commands/model-picker.test.ts | 20 ++ src/commands/onboard-custom.ts | 86 +++--- src/commands/onboard-hooks.test.ts | 15 + src/commands/onboard-hooks.ts | 14 +- src/commands/onboard-remote.ts | 79 ++--- src/commands/onboard-skills.ts | 39 ++- .../onboarding-plugin-install.test.ts | 94 ++++++ src/commands/onboarding-plugin-install.ts | 144 +++++---- src/flows/channel-setup.prompts.test.ts | 48 +++ src/flows/channel-setup.prompts.ts | 47 +-- src/flows/channel-setup.status.test.ts | 145 +++++++++ src/flows/channel-setup.status.ts | 174 ++++++++++- src/flows/channel-setup.ts | 92 +++--- src/flows/model-picker.ts | 95 +++--- src/flows/search-setup.test.ts | 38 +++ src/flows/search-setup.ts | 28 +- src/plugins/provider-auth-choice.ts | 20 +- src/wizard/setup.completion.test.ts | 33 ++ src/wizard/setup.completion.ts | 20 +- src/wizard/setup.finalize.test.ts | 82 ++++- src/wizard/setup.finalize.ts | 284 ++++++++++-------- src/wizard/setup.gateway-config.ts | 109 ++++--- src/wizard/setup.migration-import.ts | 25 +- src/wizard/setup.official-plugins.ts | 7 +- src/wizard/setup.plugin-config.ts | 45 ++- src/wizard/setup.security-note.ts | 70 +++-- src/wizard/setup.test.ts | 49 +++ src/wizard/setup.ts | 151 +++++----- 28 files changed, 1470 insertions(+), 583 deletions(-) create mode 100644 src/flows/channel-setup.prompts.test.ts diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 63141211263..2205ea390dc 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -218,6 +218,7 @@ function providerCallProviders() { } beforeEach(() => { + delete process.env.OPENCLAW_LOCALE; vi.clearAllMocks(); loadStaticManifestCatalogRowsForList.mockReturnValue([]); listProfilesForProvider.mockReturnValue([]); @@ -862,6 +863,25 @@ describe("promptModelAllowlist", () => { expect(result.scopeKeys).toEqual(["anthropic/claude-opus-4-6"]); }); + it("localizes the model allowlist picker", async () => { + process.env.OPENCLAW_LOCALE = "zh-CN"; + loadModelCatalog.mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + }, + ]); + + const multiselect = createSelectAllMultiselect(); + const prompter = makePrompter({ multiselect }); + const config = { agents: { defaults: {} } } as OpenClawConfig; + + await promptModelAllowlist({ config, prompter }); + + expect(multiselect.mock.calls[0]?.[0]?.message).toBe("/model 选择器中的模型(多选)"); + }); + it("uses static manifest catalog rows for a preferred provider without loading runtime catalog", async () => { loadStaticManifestCatalogRowsForList.mockReturnValue([ { diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index 0fd2eba0f03..8b599415cc0 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -5,6 +5,7 @@ import { ensureApiKeyFromEnvOrPrompt } from "../plugins/provider-auth-input.js"; import type { RuntimeEnv } from "../runtime.js"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; +import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyCustomApiConfig, @@ -45,23 +46,23 @@ type CustomApiCompatibilityChoice = CustomApiCompatibility | "unknown"; const COMPATIBILITY_OPTIONS: Array<{ value: CustomApiCompatibilityChoice; - label: string; - hint: string; + labelKey: string; + hintKey: string; }> = [ { value: "openai", - label: "OpenAI-compatible", - hint: "Uses /chat/completions", + labelKey: "wizard.customProvider.compatibilityOpenAi", + hintKey: "wizard.customProvider.compatibilityOpenAiHint", }, { value: "anthropic", - label: "Anthropic-compatible", - hint: "Uses /messages", + labelKey: "wizard.customProvider.compatibilityAnthropic", + hintKey: "wizard.customProvider.compatibilityAnthropicHint", }, { value: "unknown", - label: "Unknown (detect automatically)", - hint: "Probes OpenAI then Anthropic endpoints", + labelKey: "wizard.customProvider.compatibilityUnknown", + hintKey: "wizard.customProvider.compatibilityUnknownHint", }, ]; @@ -135,11 +136,11 @@ async function promptBaseUrlAndKey(params: { initialBaseUrl?: string; }): Promise<{ baseUrl: string; apiKey?: SecretInput; resolvedApiKey: string }> { const baseUrlInput = await params.prompter.text({ - message: "API Base URL", + message: t("wizard.customProvider.apiBaseUrl"), initialValue: params.initialBaseUrl, placeholder: "https://api.example.com/v1", validate: (val) => { - return URL.canParse(val) ? undefined : "Please enter a valid URL (e.g. http://...)"; + return URL.canParse(val) ? undefined : t("wizard.customProvider.validUrl"); }, }); const baseUrl = baseUrlInput.trim(); @@ -149,7 +150,7 @@ async function promptBaseUrlAndKey(params: { config: params.config, provider: providerHint, envLabel: "CUSTOM_API_KEY", - promptMessage: "API Key (leave blank if not required)", + promptMessage: t("wizard.customProvider.apiKeyPrompt"), normalize: normalizeSecretInput, validate: () => undefined, prompter: params.prompter, @@ -169,11 +170,11 @@ type CustomApiRetryChoice = "baseUrl" | "model" | "both"; async function promptCustomApiRetryChoice(prompter: WizardPrompter): Promise { return await prompter.select({ - message: "What would you like to change?", + message: t("wizard.customProvider.retryChoice"), options: [ - { value: "baseUrl", label: "Change base URL" }, - { value: "model", label: "Change model" }, - { value: "both", label: "Change base URL and model" }, + { value: "baseUrl", label: t("wizard.customProvider.changeBaseUrl") }, + { value: "model", label: t("wizard.customProvider.changeModel") }, + { value: "both", label: t("wizard.customProvider.changeBaseUrlAndModel") }, ], }); } @@ -181,9 +182,9 @@ async function promptCustomApiRetryChoice(prompter: WizardPrompter): Promise { return ( await prompter.text({ - message: "Model ID", - placeholder: "e.g. llama3, claude-3-7-sonnet", - validate: (val) => (val.trim() ? undefined : "Model ID is required"), + message: t("wizard.customProvider.modelId"), + placeholder: t("wizard.customProvider.modelIdPlaceholder"), + validate: (val) => (val.trim() ? undefined : t("wizard.customProvider.modelIdRequired")), }) ).trim(); } @@ -231,11 +232,11 @@ export async function promptCustomApiConfig(params: { let resolvedApiKey = baseInput.resolvedApiKey; const compatibilityChoice = await prompter.select({ - message: "Endpoint compatibility", + message: t("wizard.customProvider.compatibility"), options: COMPATIBILITY_OPTIONS.map((option) => ({ value: option.value, - label: option.label, - hint: option.hint, + label: t(option.labelKey), + hint: t(option.hintKey), })), }); @@ -247,14 +248,14 @@ export async function promptCustomApiConfig(params: { while (true) { let verifiedFromProbe = false; if (!compatibility) { - const probeSpinner = prompter.progress("Detecting endpoint type..."); + const probeSpinner = prompter.progress(t("wizard.customProvider.detectionProgress")); const openaiProbe = await requestOpenAiVerification({ baseUrl, apiKey: resolvedApiKey, modelId, }); if (openaiProbe.ok) { - probeSpinner.stop("Detected OpenAI-compatible endpoint."); + probeSpinner.stop(t("wizard.customProvider.detectedOpenAi")); compatibility = "openai"; verifiedFromProbe = true; } else { @@ -264,14 +265,14 @@ export async function promptCustomApiConfig(params: { modelId, }); if (anthropicProbe.ok) { - probeSpinner.stop("Detected Anthropic-compatible endpoint."); + probeSpinner.stop(t("wizard.customProvider.detectedAnthropic")); compatibility = "anthropic"; verifiedFromProbe = true; } else { - probeSpinner.stop("Could not detect endpoint type."); + probeSpinner.stop(t("wizard.customProvider.detectionFailed")); await prompter.note( - "This endpoint did not respond to OpenAI or Anthropic style requests.", - "Endpoint detection", + t("wizard.customProvider.detectionFailedNote"), + t("wizard.customProvider.detectionNoteTitle"), ); const retryChoice = await promptCustomApiRetryChoice(prompter); ({ baseUrl, apiKey, resolvedApiKey, modelId } = await applyCustomApiRetryChoice({ @@ -290,19 +291,25 @@ export async function promptCustomApiConfig(params: { break; } - const verifySpinner = prompter.progress("Verifying..."); + const verifySpinner = prompter.progress(t("wizard.customProvider.verifying")); const result = compatibility === "anthropic" ? await requestAnthropicVerification({ baseUrl, apiKey: resolvedApiKey, modelId }) : await requestOpenAiVerification({ baseUrl, apiKey: resolvedApiKey, modelId }); if (result.ok) { - verifySpinner.stop("Verification successful."); + verifySpinner.stop(t("wizard.customProvider.verificationSuccessful")); break; } if (result.status !== undefined) { - verifySpinner.stop(`Verification failed: status ${result.status}`); + verifySpinner.stop( + t("wizard.customProvider.verificationFailedStatus", { status: result.status }), + ); } else { - verifySpinner.stop(`Verification failed: ${formatVerificationError(result.error)}`); + verifySpinner.stop( + t("wizard.customProvider.verificationFailedError", { + error: formatVerificationError(result.error), + }), + ); } const retryChoice = await promptCustomApiRetryChoice(prompter); ({ baseUrl, apiKey, resolvedApiKey, modelId } = await applyCustomApiRetryChoice({ @@ -319,20 +326,20 @@ export async function promptCustomApiConfig(params: { const suggestedId = buildEndpointIdFromUrl(baseUrl); const providerIdInput = await prompter.text({ - message: "Endpoint ID", + message: t("wizard.customProvider.endpointId"), initialValue: suggestedId, placeholder: "custom", validate: (value) => { const normalized = normalizeEndpointId(value); if (!normalized) { - return "Endpoint ID is required."; + return t("wizard.customProvider.endpointIdRequired"); } return undefined; }, }); const aliasInput = await prompter.text({ - message: "Model alias (optional)", - placeholder: "e.g. local, ollama", + message: t("wizard.customProvider.modelAlias"), + placeholder: t("wizard.customProvider.modelAliasPlaceholder"), initialValue: "", validate: (value) => { const resolvedProvider = resolveCustomProviderId({ @@ -349,7 +356,7 @@ export async function promptCustomApiConfig(params: { imageInputInference.confidence === "known" ? imageInputInference.supportsImageInput : await prompter.confirm({ - message: "Does this model support image input?", + message: t("wizard.customProvider.imageInput"), initialValue: imageInputInference.supportsImageInput, }); const resolvedCompatibility = compatibility ?? "openai"; @@ -366,8 +373,11 @@ export async function promptCustomApiConfig(params: { if (result.providerIdRenamedFrom && result.providerId) { await prompter.note( - `Endpoint ID "${result.providerIdRenamedFrom}" already exists for a different base URL. Using "${result.providerId}".`, - "Endpoint ID", + t("wizard.customProvider.endpointIdRenamed", { + from: result.providerIdRenamedFrom, + to: result.providerId, + }), + t("wizard.customProvider.endpointIdTitle"), ); } diff --git a/src/commands/onboard-hooks.test.ts b/src/commands/onboard-hooks.test.ts index 9410a13dd3c..51680a87e1a 100644 --- a/src/commands/onboard-hooks.test.ts +++ b/src/commands/onboard-hooks.test.ts @@ -18,6 +18,7 @@ vi.mock("../agents/agent-scope.js", () => ({ describe("onboard-hooks", () => { beforeEach(() => { vi.clearAllMocks(); + delete process.env.OPENCLAW_LOCALE; }); const createMockPrompter = (multiselectValue: string[]): WizardPrompter => ({ @@ -166,6 +167,20 @@ describe("onboard-hooks", () => { }); }); + it("localizes built-in hook prompts when OPENCLAW_LOCALE is set", async () => { + process.env.OPENCLAW_LOCALE = "zh-CN"; + const { prompter } = await runSetupInternalHooks({ + selected: ["__skip__"], + }); + + expect(prompter.multiselect).toHaveBeenCalledWith( + expect.objectContaining({ + message: "启用 hooks?", + options: expect.arrayContaining([{ value: "__skip__", label: "暂时跳过" }]), + }), + ); + }); + it("should not enable hooks when user skips", async () => { const { result, prompter } = await runSetupInternalHooks({ selected: ["__skip__"], diff --git a/src/commands/onboard-hooks.ts b/src/commands/onboard-hooks.ts index 9549799c7da..47bc2d74a08 100644 --- a/src/commands/onboard-hooks.ts +++ b/src/commands/onboard-hooks.ts @@ -3,6 +3,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildWorkspaceHookStatus } from "../hooks/hooks-status.js"; import type { RuntimeEnv } from "../runtime.js"; +import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; export async function setupInternalHooks( @@ -17,7 +18,7 @@ export async function setupInternalHooks( "", "Learn more: https://docs.openclaw.ai/automation/hooks", ].join("\n"), - "Hooks", + t("wizard.hooks.introTitle"), ); // Discover available hooks using the hook discovery system @@ -28,17 +29,14 @@ export async function setupInternalHooks( const eligibleHooks = report.hooks.filter((h) => h.loadable); if (eligibleHooks.length === 0) { - await prompter.note( - "No eligible hooks found. You can configure hooks later in your config.", - "No Hooks Available", - ); + await prompter.note(t("wizard.hooks.noHooksMessage"), t("wizard.hooks.noHooksTitle")); return cfg; } const toEnable = await prompter.multiselect({ - message: "Enable hooks?", + message: t("wizard.hooks.enable"), options: [ - { value: "__skip__", label: "Skip for now" }, + { value: "__skip__", label: t("common.skipForNow") }, ...eligibleHooks.map((hook) => ({ value: hook.name, label: `${hook.emoji ?? "🔗"} ${hook.name}`, @@ -78,7 +76,7 @@ export async function setupInternalHooks( ` ${formatCliCommand("openclaw hooks enable ")}`, ` ${formatCliCommand("openclaw hooks disable ")}`, ].join("\n"), - "Hooks Configured", + t("wizard.hooks.configuredTitle"), ); return next; diff --git a/src/commands/onboard-remote.ts b/src/commands/onboard-remote.ts index 1e1e0ce9e89..5a2504b050a 100644 --- a/src/commands/onboard-remote.ts +++ b/src/commands/onboard-remote.ts @@ -10,6 +10,7 @@ import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; import { resolveSecretInputModeForEnvSelection } from "../plugins/provider-auth-mode.js"; import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js"; import { maskApiKey } from "../utils/mask-api-key.js"; +import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { detectBinary } from "./onboard-helpers.js"; import type { SecretInputMode } from "./onboard-types.js"; @@ -31,17 +32,14 @@ function ensureWsUrl(value: string): string { function validateGatewayWebSocketUrl(value: string): string | undefined { const trimmed = value.trim(); if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) { - return "URL must start with ws:// or wss://"; + return t("wizard.remote.validWebSocketUrl"); } if ( !isSecureWebSocketUrl(trimmed, { allowPrivateWs: process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1", }) ) { - return ( - "Use wss:// for remote hosts, or ws://127.0.0.1/localhost via SSH tunnel. " + - "Break-glass: OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 for trusted private networks." - ); + return t("wizard.remote.insecureRemoteUrl"); } return undefined; } @@ -59,7 +57,7 @@ export async function promptRemoteGatewayConfig( const hasBonjourTool = (await detectBinary("dns-sd")) || (await detectBinary("avahi-browse")); const wantsDiscover = hasBonjourTool ? await prompter.confirm({ - message: "Discover gateway on LAN (Bonjour)?", + message: t("wizard.remote.bonjour"), initialValue: true, }) : false; @@ -78,19 +76,23 @@ export async function promptRemoteGatewayConfig( const wideAreaDomain = resolveWideAreaDiscoveryDomain({ configDomain: cfg.discovery?.wideArea?.domain, }); - const spin = prompter.progress("Searching for gateways…"); + const spin = prompter.progress(t("wizard.remote.searchProgress")); const beacons = await discoverGatewayBeacons({ timeoutMs: 2000, wideAreaDomain }); - spin.stop(beacons.length > 0 ? `Found ${beacons.length} gateway(s)` : "No gateways found"); + spin.stop( + beacons.length > 0 + ? t("wizard.remote.foundGateways", { count: beacons.length }) + : t("wizard.remote.noGatewaysFound"), + ); if (beacons.length > 0) { const selection = await prompter.select({ - message: "Select gateway", + message: t("wizard.remote.selectGateway"), options: [ ...beacons.map((beacon, index) => ({ value: String(index), label: buildLabel(beacon), })), - { value: "manual", label: "Enter URL manually" }, + { value: "manual", label: t("wizard.remote.enterUrlManually") }, ], }); if (selection !== "manual") { @@ -105,20 +107,23 @@ export async function promptRemoteGatewayConfig( if (target.endpoint) { const { host, port } = target.endpoint; const mode = await prompter.select({ - message: "Connection method", + message: t("wizard.remote.connectionMethod"), options: [ { value: "direct", label: `Direct gateway WS (${host}:${port})`, }, - { value: "ssh", label: "SSH tunnel (loopback)" }, + { value: "ssh", label: t("wizard.remote.sshTunnel") }, ], }); if (mode === "direct") { suggestedUrl = `wss://${host}:${port}`; const fingerprint = target.endpoint.gatewayTlsFingerprintSha256; const trusted = await prompter.confirm({ - message: `Trust this gateway? Host: ${host}:${port} TLS fingerprint: ${fingerprint ?? "not advertised (connection will not be pinned)"}`, + message: t("wizard.remote.trustGateway", { + host: `${host}:${port}`, + fingerprint: fingerprint ?? t("wizard.remote.fingerprintMissing"), + }), initialValue: false, }); if (trusted) { @@ -126,12 +131,12 @@ export async function promptRemoteGatewayConfig( trustedDiscoveryUrl = suggestedUrl; await prompter.note( [ - "Direct remote access defaults to TLS.", + t("wizard.remote.directDefaultsTls"), `Using: ${suggestedUrl}`, ...(fingerprint ? [`TLS pin: ${fingerprint}`] : []), - "If your gateway is loopback-only, choose SSH tunnel and keep ws://127.0.0.1:18789.", + t("wizard.remote.loopbackSshHint"), ].join("\n"), - "Direct remote", + t("wizard.remote.directAccessTitle"), ); } else { // Clear the discovered endpoint so the manual prompt falls back to a safe default. @@ -145,14 +150,14 @@ export async function promptRemoteGatewayConfig( `ssh -N -L 18789:127.0.0.1:18789 @${host}${target.sshPort ? ` -p ${target.sshPort}` : ""}`, "Docs: https://docs.openclaw.ai/gateway/remote", ].join("\n"), - "SSH tunnel", + t("wizard.remote.sshTunnelTitle"), ); } } } const urlInput = await prompter.text({ - message: "Gateway WebSocket URL", + message: t("wizard.remote.websocketUrl"), initialValue: suggestedUrl, validate: (value) => validateGatewayWebSocketUrl(value), }); @@ -161,11 +166,11 @@ export async function promptRemoteGatewayConfig( discoveryTlsFingerprint && url === trustedDiscoveryUrl ? discoveryTlsFingerprint : undefined; const authChoice = await prompter.select({ - message: "Gateway auth", + message: t("wizard.remote.auth"), options: [ - { value: "token", label: "Token (recommended)" }, - { value: "password", label: "Password" }, - { value: "off", label: "No auth" }, + { value: "token", label: t("common.tokenRecommended") }, + { value: "password", label: t("common.password") }, + { value: "off", label: t("common.noAuth") }, ], }); @@ -176,9 +181,9 @@ export async function promptRemoteGatewayConfig( prompter, explicitMode: options?.secretInputMode, copy: { - modeMessage: "How do you want to provide this gateway token?", - plaintextLabel: "Enter token now", - plaintextHint: "Stores the token directly in OpenClaw config", + modeMessage: t("wizard.gateway.remoteTokenMode"), + plaintextLabel: t("wizard.remote.plaintextTokenLabel"), + plaintextHint: t("wizard.remote.plaintextTokenHint"), }, }); if (selectedMode === "ref") { @@ -188,7 +193,7 @@ export async function promptRemoteGatewayConfig( prompter, preferredEnvVar: "OPENCLAW_GATEWAY_TOKEN", copy: { - sourceMessage: "Where is this gateway token stored?", + sourceMessage: t("wizard.remote.gatewayTokenStoredMessage"), envVarPlaceholder: "OPENCLAW_GATEWAY_TOKEN", }, }); @@ -198,7 +203,7 @@ export async function promptRemoteGatewayConfig( if ( existingToken && (await prompter.confirm({ - message: `Use existing gateway token (${maskApiKey(existingToken)})?`, + message: t("wizard.gateway.existingTokenConfirm", { token: maskApiKey(existingToken) }), initialValue: true, })) ) { @@ -206,8 +211,8 @@ export async function promptRemoteGatewayConfig( } else { token = ( await prompter.text({ - message: "Gateway token", - validate: (value) => (value?.trim() ? undefined : "Required"), + message: t("wizard.remote.tokenPrompt"), + validate: (value) => (value?.trim() ? undefined : t("common.required")), sensitive: true, }) ).trim(); @@ -219,9 +224,9 @@ export async function promptRemoteGatewayConfig( prompter, explicitMode: options?.secretInputMode, copy: { - modeMessage: "How do you want to provide this gateway password?", - plaintextLabel: "Enter password now", - plaintextHint: "Stores the password directly in OpenClaw config", + modeMessage: t("wizard.gateway.remotePasswordMode"), + plaintextLabel: t("wizard.remote.plaintextPasswordLabel"), + plaintextHint: t("wizard.remote.plaintextPasswordHint"), }, }); if (selectedMode === "ref") { @@ -231,7 +236,7 @@ export async function promptRemoteGatewayConfig( prompter, preferredEnvVar: "OPENCLAW_GATEWAY_PASSWORD", copy: { - sourceMessage: "Where is this gateway password stored?", + sourceMessage: t("wizard.remote.gatewayPasswordStoredMessage"), envVarPlaceholder: "OPENCLAW_GATEWAY_PASSWORD", }, }); @@ -241,7 +246,9 @@ export async function promptRemoteGatewayConfig( if ( existingPassword && (await prompter.confirm({ - message: `Use existing gateway password (${maskApiKey(existingPassword)})?`, + message: t("wizard.gateway.existingPasswordConfirm", { + password: maskApiKey(existingPassword), + }), initialValue: true, })) ) { @@ -249,8 +256,8 @@ export async function promptRemoteGatewayConfig( } else { password = ( await prompter.text({ - message: "Gateway password", - validate: (value) => (value?.trim() ? undefined : "Required"), + message: t("wizard.remote.passwordPrompt"), + validate: (value) => (value?.trim() ? undefined : t("common.required")), sensitive: true, }) ).trim(); diff --git a/src/commands/onboard-skills.ts b/src/commands/onboard-skills.ts index 125c48c3a03..40c4284fa6c 100644 --- a/src/commands/onboard-skills.ts +++ b/src/commands/onboard-skills.ts @@ -4,6 +4,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; +import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { detectBinary, resolveNodeManagerOptions } from "./onboard-helpers.js"; @@ -70,11 +71,11 @@ export async function setupSkills( `Unsupported on this OS: ${unsupportedOs.length}`, `Blocked by allowlist: ${blocked.length}`, ].join("\n"), - "Skills status", + t("wizard.skills.statusTitle"), ); const shouldConfigure = await prompter.confirm({ - message: "Configure skills now? (recommended)", + message: t("wizard.skills.configure"), initialValue: true, }); if (!shouldConfigure) { @@ -87,12 +88,12 @@ export async function setupSkills( let next: OpenClawConfig = cfg; if (installable.length > 0) { const toInstall = await prompter.multiselect({ - message: "Install missing skill dependencies", + message: t("wizard.skills.installDeps"), options: [ { value: "__skip__", - label: "Skip for now", - hint: "Continue without installing dependencies", + label: t("common.skipForNow"), + hint: t("wizard.skills.skipDepsHint"), }, ...installable.map((skill) => ({ value: skill.name, @@ -119,10 +120,10 @@ export async function setupSkills( "Many skill dependencies are shipped via Homebrew.", "Without brew, you'll need to build from source or download releases manually.", ].join("\n"), - "Homebrew recommended", + t("wizard.skills.homebrewRecommendedTitle"), ); const showBrewInstall = await prompter.confirm({ - message: "Show Homebrew install command?", + message: t("wizard.skills.homebrewCommand"), initialValue: true, }); if (showBrewInstall) { @@ -131,7 +132,7 @@ export async function setupSkills( "Run:", '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', ].join("\n"), - "Homebrew install", + t("wizard.skills.homebrewInstallTitle"), ); } } @@ -141,7 +142,7 @@ export async function setupSkills( ); if (needsNodeManagerPrompt) { const nodeManager = (await prompter.select({ - message: "Preferred node manager for skill installs", + message: t("wizard.skills.nodeManager"), options: resolveNodeManagerOptions(), })) as "npm" | "pnpm" | "bun"; next = { @@ -165,7 +166,7 @@ export async function setupSkills( if (!installId) { continue; } - const spin = prompter.progress(`Installing ${name}…`); + const spin = prompter.progress(t("wizard.skills.installing", { name })); const result = await installSkill({ workspaceDir, skillName: target.name, @@ -174,7 +175,11 @@ export async function setupSkills( }); const warnings = result.warnings ?? []; if (result.ok) { - spin.stop(warnings.length > 0 ? `Installed ${name} (with warnings)` : `Installed ${name}`); + spin.stop( + warnings.length > 0 + ? t("wizard.skills.installedWithWarnings", { name }) + : t("wizard.skills.installed", { name }), + ); for (const warning of warnings) { runtime.log(warning); } @@ -182,7 +187,9 @@ export async function setupSkills( } const code = result.code == null ? "" : ` (exit ${result.code})`; const detail = summarizeInstallFailure(result.message); - spin.stop(`Install failed: ${name}${code}${detail ? ` — ${detail}` : ""}`); + spin.stop( + t("wizard.skills.installFailed", { name, code, detail: detail ? ` - ${detail}` : "" }), + ); for (const warning of warnings) { runtime.log(warning); } @@ -194,7 +201,7 @@ export async function setupSkills( runtime.log( `Tip: run \`${formatCliCommand("openclaw doctor")}\` to review skills + requirements.`, ); - runtime.log("Docs: https://docs.openclaw.ai/skills"); + runtime.log(t("wizard.skills.docsLine")); } } @@ -203,15 +210,15 @@ export async function setupSkills( continue; } const wantsKey = await prompter.confirm({ - message: `Set ${skill.primaryEnv} for ${skill.name}?`, + message: t("wizard.skills.setEnv", { env: skill.primaryEnv, name: skill.name }), initialValue: false, }); if (!wantsKey) { continue; } const apiKey = await prompter.text({ - message: `Enter ${skill.primaryEnv}`, - validate: (value) => (value?.trim() ? undefined : "Required"), + message: t("wizard.skills.enterEnv", { env: skill.primaryEnv }), + validate: (value) => (value?.trim() ? undefined : t("common.required")), sensitive: true, }); next = upsertSkillEntry(next, skill.skillKey, { apiKey: normalizeSecretInput(apiKey) }); diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index cd832c2fcb7..560f698b172 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -163,6 +163,100 @@ describe("ensureOnboardingPluginInstalled", () => { refreshPluginRegistryAfterConfigMutation.mockResolvedValue(undefined); }); + it("localizes plugin install choices", async () => { + const previousLocale = process.env.OPENCLAW_LOCALE; + process.env.OPENCLAW_LOCALE = "zh-CN"; + let captured: + | { + message: string; + options: Array<{ + value: "clawhub" | "npm" | "local" | "skip"; + label: string; + hint?: string; + }>; + } + | undefined; + + try { + await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "qqbot", + label: "QQ Bot", + install: { + npmSpec: "@openclaw/qqbot@beta", + }, + }, + prompter: { + select: vi.fn(async (input) => { + captured = input; + return "skip"; + }), + } as never, + runtime: {} as never, + }); + + expect(captured?.message).toBe("安装 QQ Bot 插件?"); + expect(captured?.options).toEqual([ + { value: "npm", label: "从 npm 下载(@openclaw/qqbot@beta)" }, + { value: "skip", label: "暂时跳过" }, + ]); + } finally { + if (previousLocale === undefined) { + delete process.env.OPENCLAW_LOCALE; + } else { + process.env.OPENCLAW_LOCALE = previousLocale; + } + } + }); + + it("localizes plugin install progress and enablement failures", async () => { + const previousLocale = process.env.OPENCLAW_LOCALE; + process.env.OPENCLAW_LOCALE = "zh-CN"; + enablePluginInConfig.mockReturnValueOnce({ + config: {}, + enabled: false, + pluginId: "demo-plugin", + reason: "blocked by allowlist", + }); + installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "demo-plugin", + targetDir: "/tmp/demo-plugin", + version: "1.2.3", + }); + const note = vi.fn(async () => {}); + const progress = vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })); + + try { + await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + npmSpec: "@demo/plugin@1.2.3", + }, + }, + prompter: { + select: vi.fn(async () => "npm"), + note, + progress, + } as never, + runtime: { error: vi.fn() } as never, + }); + + expect(progress).toHaveBeenCalledWith("正在安装 Demo Plugin 插件..."); + expect(note).toHaveBeenCalledWith("无法启用 Demo Plugin:blocked by allowlist。", "插件安装"); + } finally { + if (previousLocale === undefined) { + delete process.env.OPENCLAW_LOCALE; + } else { + process.env.OPENCLAW_LOCALE = previousLocale; + } + } + }); + it("refuses non-skipped installs in Nix mode before package work", async () => { const previous = process.env.OPENCLAW_NIX_MODE; process.env.OPENCLAW_NIX_MODE = "1"; diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index b03a2bf2821..cd8ed8591ef 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -35,6 +35,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { sanitizeTerminalText } from "../terminal/safe-text.js"; import { withTimeout } from "../utils/with-timeout.js"; import { VERSION } from "../version.js"; +import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; type InstallChoice = "clawhub" | "npm" | "local" | "skip"; @@ -368,19 +369,19 @@ async function promptInstallChoice(params: { if (safeClawHubSpec) { options.push({ value: "clawhub", - label: `Download from ClawHub (${safeClawHubSpec})`, + label: t("wizard.plugins.downloadFromClawHub", { spec: safeClawHubSpec }), }); } if (safeNpmSpec) { options.push({ value: "npm", - label: `Download from npm (${safeNpmSpec})`, + label: t("wizard.plugins.downloadFromNpm", { spec: safeNpmSpec }), }); } if (params.localPath) { options.push({ value: "local", - label: "Use local plugin path", + label: t("wizard.plugins.useLocalPluginPath"), ...(safeLocalPath ? { hint: safeLocalPath } : {}), }); } @@ -401,7 +402,7 @@ async function promptInstallChoice(params: { } } - options.push({ value: "skip", label: "Skip for now" }); + options.push({ value: "skip", label: t("common.skipForNow") }); const initialValue = params.defaultChoice === "local" && !params.localPath @@ -425,7 +426,7 @@ async function promptInstallChoice(params: { : params.defaultChoice; return await params.prompter.select({ - message: `Install ${safeLabel} plugin?`, + message: t("wizard.plugins.installPluginPrompt", { plugin: safeLabel }), options, initialValue, }); @@ -434,10 +435,36 @@ async function promptInstallChoice(params: { function formatDurationLabel(timeoutMs: number): string { if (timeoutMs % 60_000 === 0) { const minutes = timeoutMs / 60_000; - return `${minutes} minute${minutes === 1 ? "" : "s"}`; + return t(minutes === 1 ? "common.minute" : "common.minutes", { count: minutes }); } const seconds = Math.round(timeoutMs / 1000); - return `${seconds} second${seconds === 1 ? "" : "s"}`; + return t(seconds === 1 ? "common.second" : "common.seconds", { count: seconds }); +} + +function formatPluginInstallProgress(label: string): string { + return t("wizard.plugins.installingPlugin", { plugin: label }); +} + +function formatPluginInstalled(label: string): string { + return t("wizard.plugins.installedPlugin", { plugin: label }); +} + +function formatPluginInstallFailed(label: string): string { + return t("wizard.plugins.installFailedShort", { plugin: label }); +} + +function formatPluginInstallTimedOut(label: string): string { + return t("wizard.plugins.installTimedOutShort", { plugin: label }); +} + +function formatPluginInstallTimedOutNote(spec: string): string { + return [ + t("wizard.plugins.installTimedOut", { + spec, + duration: formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS), + }), + t("wizard.plugins.returningToSelection"), + ].join("\n"); } function summarizeInstallError(message: string): string { @@ -467,7 +494,10 @@ async function applyPluginEnablement(params: { } const safeLabel = sanitizeTerminalText(params.label); const reason = enableResult.reason ?? "plugin disabled"; - await params.prompter.note(`Cannot enable ${safeLabel}: ${reason}.`, "Plugin install"); + await params.prompter.note( + t("wizard.plugins.enableFailed", { plugin: safeLabel, reason }), + t("wizard.plugins.installTitle"), + ); params.runtime.error?.( `Plugin install failed: ${sanitizeTerminalText(params.pluginId)} is disabled (${reason}).`, ); @@ -610,9 +640,9 @@ async function installPluginFromNpmSpecWithProgress(params: { } > { const safeLabel = sanitizeTerminalText(params.entry.label); - const progress = params.prompter.progress(`Installing ${safeLabel} plugin…`); + const progress = params.prompter.progress(formatPluginInstallProgress(safeLabel)); const animated = createAnimatedInstallProgress(progress); - animated.setLabel("Preparing"); + animated.setLabel(t("wizard.plugins.preparingInstall")); const updateProgress = (message: string) => { const sanitized = sanitizeTerminalText(message).trim(); if (!sanitized) { @@ -646,9 +676,9 @@ async function installPluginFromNpmSpecWithProgress(params: { ); animated.stop(); if (result.ok) { - progress.stop(`Installed ${safeLabel} plugin`); + progress.stop(formatPluginInstalled(safeLabel)); } else { - progress.stop(`Install failed: ${safeLabel}`); + progress.stop(formatPluginInstallFailed(safeLabel)); } return { status: "completed", @@ -657,10 +687,10 @@ async function installPluginFromNpmSpecWithProgress(params: { } catch (error) { animated.stop(); if (isTimeoutError(error)) { - progress.stop(`Install timed out: ${safeLabel}`); + progress.stop(formatPluginInstallTimedOut(safeLabel)); return { status: "timed_out" }; } - progress.stop(`Install failed: ${safeLabel}`); + progress.stop(formatPluginInstallFailed(safeLabel)); return { status: "completed", result: { @@ -684,9 +714,9 @@ async function installPluginFromNpmPackArchiveWithProgress(params: { } > { const safeLabel = sanitizeTerminalText(params.entry.label); - const progress = params.prompter.progress(`Installing ${safeLabel} plugin…`); + const progress = params.prompter.progress(formatPluginInstallProgress(safeLabel)); const animated = createAnimatedInstallProgress(progress); - animated.setLabel("Preparing"); + animated.setLabel(t("wizard.plugins.preparingInstall")); const updateProgress = (message: string) => { const sanitized = sanitizeTerminalText(message).trim(); if (!sanitized) { @@ -714,15 +744,17 @@ async function installPluginFromNpmPackArchiveWithProgress(params: { ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS, ); animated.stop(); - progress.stop(result.ok ? `Installed ${safeLabel} plugin` : `Install failed: ${safeLabel}`); + progress.stop( + result.ok ? formatPluginInstalled(safeLabel) : formatPluginInstallFailed(safeLabel), + ); return { status: "completed", result }; } catch (error) { animated.stop(); if (isTimeoutError(error)) { - progress.stop(`Install timed out: ${safeLabel}`); + progress.stop(formatPluginInstallTimedOut(safeLabel)); return { status: "timed_out" }; } - progress.stop(`Install failed: ${safeLabel}`); + progress.stop(formatPluginInstallFailed(safeLabel)); throw error; } finally { animated.stop(); @@ -762,11 +794,8 @@ async function installPluginFromOverride(params: { : `npm-pack:${params.override.archivePath}`; if (installOutcome.status === "timed_out") { await prompter.note( - [ - `Installing ${sanitizeTerminalText(displaySpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`, - "Returning to selection.", - ].join("\n"), - "Plugin install", + formatPluginInstallTimedOutNote(sanitizeTerminalText(displaySpec)), + t("wizard.plugins.installTitle"), ); runtime.error?.( `Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(displaySpec)}`, @@ -783,10 +812,13 @@ async function installPluginFromOverride(params: { if (!result.ok) { await prompter.note( [ - `Failed to install ${sanitizeTerminalText(displaySpec)}: ${summarizeInstallError(result.error)}`, - "Returning to selection.", + t("wizard.plugins.installFailed", { + spec: sanitizeTerminalText(displaySpec), + error: summarizeInstallError(result.error), + }), + t("wizard.plugins.returningToSelection"), ].join("\n"), - "Plugin install", + t("wizard.plugins.installTitle"), ); runtime.error?.(`Plugin install failed: ${sanitizeTerminalText(result.error)}`); return { @@ -863,9 +895,9 @@ async function installPluginFromClawHubSpecWithProgress(params: { } > { const safeLabel = sanitizeTerminalText(params.entry.label); - const progress = params.prompter.progress(`Installing ${safeLabel} plugin…`); + const progress = params.prompter.progress(formatPluginInstallProgress(safeLabel)); const animated = createAnimatedInstallProgress(progress); - animated.setLabel("Preparing"); + animated.setLabel(t("wizard.plugins.preparingInstall")); const updateProgress = (message: string) => { const sanitized = sanitizeTerminalText(message).trim(); if (!sanitized) { @@ -895,9 +927,9 @@ async function installPluginFromClawHubSpecWithProgress(params: { ); animated.stop(); if (result.ok) { - progress.stop(`Installed ${safeLabel} plugin`); + progress.stop(formatPluginInstalled(safeLabel)); } else { - progress.stop(`Install failed: ${safeLabel}`); + progress.stop(formatPluginInstallFailed(safeLabel)); } return { status: "completed", @@ -906,10 +938,10 @@ async function installPluginFromClawHubSpecWithProgress(params: { } catch (error) { animated.stop(); if (isTimeoutError(error)) { - progress.stop(`Install timed out: ${safeLabel}`); + progress.stop(formatPluginInstallTimedOut(safeLabel)); return { status: "timed_out" }; } - progress.stop(`Install failed: ${safeLabel}`); + progress.stop(formatPluginInstallFailed(safeLabel)); return { status: "completed", result: { @@ -1052,11 +1084,8 @@ export async function ensureOnboardingPluginInstalled(params: { if (installOutcome.status === "timed_out") { await prompter.note( - [ - `Installing ${sanitizeTerminalText(clawhubInstallSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`, - "Returning to selection.", - ].join("\n"), - "Plugin install", + formatPluginInstallTimedOutNote(sanitizeTerminalText(clawhubInstallSpec)), + t("wizard.plugins.installTitle"), ); runtime.error?.( `Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(clawhubInstallSpec)}`, @@ -1103,10 +1132,13 @@ export async function ensureOnboardingPluginInstalled(params: { await prompter.note( [ - `Failed to install ${sanitizeTerminalText(clawhubInstallSpec)}: ${summarizeInstallError(result.error)}`, - "Returning to selection.", + t("wizard.plugins.installFailed", { + spec: sanitizeTerminalText(clawhubInstallSpec), + error: summarizeInstallError(result.error), + }), + t("wizard.plugins.returningToSelection"), ].join("\n"), - "Plugin install", + t("wizard.plugins.installTitle"), ); if (!npmInstallSpec || !shouldFallbackClawHubToNpm(result)) { @@ -1120,7 +1152,9 @@ export async function ensureOnboardingPluginInstalled(params: { } shouldTryNpm = await prompter.confirm({ - message: `Use npm package instead? (${sanitizeTerminalText(npmInstallSpec)})`, + message: t("wizard.plugins.useNpmPackageInstead", { + spec: sanitizeTerminalText(npmInstallSpec), + }), initialValue: true, }); if (!shouldTryNpm) { @@ -1136,8 +1170,10 @@ export async function ensureOnboardingPluginInstalled(params: { if (!shouldTryNpm || !npmInstallSpec) { await prompter.note( - `No remote install source is available for ${sanitizeTerminalText(entry.label)}. Returning to selection.`, - "Plugin install", + t("wizard.plugins.noRemoteInstallSource", { + plugin: sanitizeTerminalText(entry.label), + }), + t("wizard.plugins.installTitle"), ); runtime.error?.( `Plugin install failed: no remote spec available for ${sanitizeTerminalText(entry.pluginId)}.`, @@ -1159,11 +1195,8 @@ export async function ensureOnboardingPluginInstalled(params: { if (installOutcome.status === "timed_out") { await prompter.note( - [ - `Installing ${sanitizeTerminalText(npmInstallSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`, - "Returning to selection.", - ].join("\n"), - "Plugin install", + formatPluginInstallTimedOutNote(sanitizeTerminalText(npmInstallSpec)), + t("wizard.plugins.installTitle"), ); runtime.error?.( `Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(npmInstallSpec)}`, @@ -1214,15 +1247,20 @@ export async function ensureOnboardingPluginInstalled(params: { await prompter.note( [ - `Failed to install ${sanitizeTerminalText(npmInstallSpec)}: ${summarizeInstallError(result.error)}`, - "Returning to selection.", + t("wizard.plugins.installFailed", { + spec: sanitizeTerminalText(npmInstallSpec), + error: summarizeInstallError(result.error), + }), + t("wizard.plugins.returningToSelection"), ].join("\n"), - "Plugin install", + t("wizard.plugins.installTitle"), ); if (localPath) { const fallback = await prompter.confirm({ - message: `Use local plugin path instead? (${sanitizeTerminalText(localPath)})`, + message: t("wizard.plugins.useLocalPluginPathInstead", { + path: sanitizeTerminalText(localPath), + }), initialValue: true, }); if (fallback) { diff --git a/src/flows/channel-setup.prompts.test.ts b/src/flows/channel-setup.prompts.test.ts new file mode 100644 index 00000000000..156c2f44821 --- /dev/null +++ b/src/flows/channel-setup.prompts.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelSetupDmPolicy } from "../commands/channel-setup/types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { maybeConfigureDmPolicies } from "./channel-setup.prompts.js"; + +beforeEach(() => { + delete process.env.OPENCLAW_LOCALE; +}); + +describe("maybeConfigureDmPolicies", () => { + it("localizes DM policy guidance and options", async () => { + process.env.OPENCLAW_LOCALE = "zh-CN"; + const note = vi.fn(async () => {}); + const select = vi.fn(async () => "pairing") as unknown as WizardPrompter["select"]; + const prompter = { + confirm: vi.fn(async () => true), + note, + select, + } as unknown as WizardPrompter; + const policy: ChannelSetupDmPolicy = { + label: "Telegram", + channel: "telegram" as ChannelSetupDmPolicy["channel"], + policyKey: "channels.telegram.dmPolicy", + allowFromKey: "channels.telegram.allowFrom", + getCurrent: () => "pairing", + setPolicy: (cfg: OpenClawConfig) => cfg, + }; + + await maybeConfigureDmPolicies({ + cfg: {}, + selection: ["telegram" as never], + prompter, + resolveAdapter: () => ({ dmPolicy: policy }) as never, + }); + + expect(note.mock.calls[0]?.[0]).toContain("默认:配对"); + expect(note.mock.calls[0]?.[1]).toBe("Telegram DM 访问"); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Telegram DM 策略", + options: expect.arrayContaining([ + expect.objectContaining({ label: "配对(推荐)", value: "pairing" }), + ]), + }), + ); + }); +}); diff --git a/src/flows/channel-setup.prompts.ts b/src/flows/channel-setup.prompts.ts index 0241f0e2f9a..34142314f6e 100644 --- a/src/flows/channel-setup.prompts.ts +++ b/src/flows/channel-setup.prompts.ts @@ -11,6 +11,7 @@ import type { DmPolicy } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { formatDocsLink } from "../terminal/links.js"; +import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip"; @@ -29,13 +30,13 @@ export async function promptConfiguredAction(params: { const options: Array> = [ { value: "update", - label: "Modify settings", + label: t("wizard.channels.modifySettings"), }, ...(supportsDisable ? [ { value: "disable" as const, - label: "Disable (keeps config)", + label: t("wizard.channels.disableKeepConfig"), }, ] : []), @@ -43,17 +44,17 @@ export async function promptConfiguredAction(params: { ? [ { value: "delete" as const, - label: "Delete config", + label: t("wizard.channels.deleteConfig"), }, ] : []), { value: "skip", - label: "Skip (leave as-is)", + label: t("wizard.channels.skipLeaveAsIs"), }, ]; return await prompter.select({ - message: `${label} already configured. What do you want to do?`, + message: t("wizard.channels.configuredAction", { label }), options, initialValue: "update", }); @@ -77,7 +78,7 @@ export async function promptRemovalAccountId(params: { return defaultAccountId; } const selected = await prompter.select({ - message: `${label} account`, + message: t("wizard.channels.account", { label }), options: accountIds.map((accountId) => ({ value: accountId, label: formatAccountLabel(accountId), @@ -104,7 +105,7 @@ export async function maybeConfigureDmPolicies(params: { } const wants = await prompter.confirm({ - message: "Configure DM access policies now? (default: pairing)", + message: t("wizard.channels.configureDmPolicies"), initialValue: false, }); if (!wants) { @@ -120,24 +121,28 @@ export async function maybeConfigureDmPolicies(params: { }; await prompter.note( [ - "Default: pairing (unknown DMs get a pairing code).", - `Approve: ${formatCliCommand(`openclaw pairing approve ${policy.channel} `)}`, - `Allowlist DMs: ${policyKey}="allowlist" + ${allowFromKey} entries.`, - `Public DMs: ${policyKey}="open" + ${allowFromKey} includes "*".`, - "Multi-user DMs: run: " + - formatCliCommand('openclaw config set session.dmScope "per-channel-peer"') + - ' (or "per-account-channel-peer" for multi-account channels) to isolate sessions.', - `Docs: ${formatDocsLink("/channels/pairing", "channels/pairing")}`, + t("wizard.channels.dmPolicyDefault"), + t("wizard.channels.dmPolicyApprove", { + command: formatCliCommand(`openclaw pairing approve ${policy.channel} `), + }), + t("wizard.channels.dmPolicyAllowlist", { allowFromKey, policyKey }), + t("wizard.channels.dmPolicyOpen", { allowFromKey, policyKey }), + t("wizard.channels.dmPolicyMultiUser", { + command: formatCliCommand('openclaw config set session.dmScope "per-channel-peer"'), + }), + t("wizard.channels.docs", { + link: formatDocsLink("/channels/pairing", "channels/pairing"), + }), ].join("\n"), - `${policy.label} DM access`, + t("wizard.channels.dmAccessTitle", { label: policy.label }), ); const nextPolicy = (await prompter.select({ - message: `${policy.label} DM policy`, + message: t("wizard.channels.dmPolicy", { label: policy.label }), options: [ - { value: "pairing", label: "Pairing (recommended)" }, - { value: "allowlist", label: "Allowlist (specific users only)" }, - { value: "open", label: "Open (public inbound DMs)" }, - { value: "disabled", label: "Disabled (ignore DMs)" }, + { value: "pairing", label: t("wizard.channels.dmPolicyPairing") }, + { value: "allowlist", label: t("wizard.channels.dmPolicyAllowlistOption") }, + { value: "open", label: t("wizard.channels.dmPolicyOpenOption") }, + { value: "disabled", label: t("wizard.channels.dmPolicyDisabledOption") }, ], })) as DmPolicy; const current = policy.getCurrent(cfg, accountId); diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index daed3c1a3d4..eff2a023881 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -75,6 +75,7 @@ vi.mock("../plugins/bundled-sources.js", () => ({ })); let collectChannelStatus: ChannelSetupStatusModule["collectChannelStatus"]; +let noteChannelStatus: ChannelSetupStatusModule["noteChannelStatus"]; let noteChannelPrimer: ChannelSetupStatusModule["noteChannelPrimer"]; let resolveChannelSelectionNoteLines: ChannelSetupStatusModule["resolveChannelSelectionNoteLines"]; let resolveChannelSetupSelectionContributions: ChannelSetupStatusModule["resolveChannelSetupSelectionContributions"]; @@ -106,6 +107,7 @@ describe("resolveChannelSetupSelectionContributions", () => { isChannelConfigured.mockReturnValue(false); ({ collectChannelStatus, + noteChannelStatus, noteChannelPrimer, resolveChannelSelectionNoteLines, resolveChannelSetupSelectionContributions, @@ -263,6 +265,67 @@ describe("resolveChannelSetupSelectionContributions", () => { ]); }); + it("localizes channel status note labels", async () => { + const previousLocale = process.env.OPENCLAW_LOCALE; + process.env.OPENCLAW_LOCALE = "zh-CN"; + listChatChannels.mockReturnValue([ + makeMeta("discord", "Discord"), + makeMeta("telegram", "Telegram"), + ]); + isChannelConfigured.mockImplementation((_, channelId) => channelId === "discord"); + resolveChannelSetupEntries.mockReturnValue( + makeChannelSetupEntries({ + installedCatalogEntries: [makeCatalogEntry("matrix", "Matrix")], + installableCatalogEntries: [makeCatalogEntry("zalo", "Zalo")], + }), + ); + + try { + const summary = await collectChannelStatus({ + cfg: {} as never, + accountOverrides: {}, + installedPlugins: [], + }); + + expect(summary.statusLines).toEqual([ + "Discord: 已配置(插件已禁用)", + "Telegram: 未配置", + "Matrix: 已安装", + "Zalo: 安装插件后启用", + ]); + } finally { + if (previousLocale === undefined) { + delete process.env.OPENCLAW_LOCALE; + } else { + process.env.OPENCLAW_LOCALE = previousLocale; + } + } + }); + + it("localizes channel status note title", async () => { + const previousLocale = process.env.OPENCLAW_LOCALE; + process.env.OPENCLAW_LOCALE = "zh-CN"; + const note = vi.fn(async () => {}); + listChatChannels.mockReturnValue([makeMeta("discord", "Discord")]); + isChannelConfigured.mockReturnValue(true); + + try { + await noteChannelStatus({ + cfg: {} as never, + prompter: { note } as never, + installedPlugins: [], + }); + + expect(note).toHaveBeenCalledWith(expect.any(String), "频道状态"); + } finally { + if (previousLocale === undefined) { + delete process.env.OPENCLAW_LOCALE; + } else { + process.env.OPENCLAW_LOCALE = previousLocale; + } + } + }); + it("sanitizes channel metadata before primer notes", async () => { const note = vi.fn(async () => undefined); @@ -297,6 +360,42 @@ describe("resolveChannelSetupSelectionContributions", () => { ); }); + it("localizes built-in channel primer copy", async () => { + const previousLocale = process.env.OPENCLAW_LOCALE; + process.env.OPENCLAW_LOCALE = "zh-CN"; + const note = vi.fn(async () => undefined); + + try { + await noteChannelPrimer( + { note } as never, + [ + { + id: "discord", + label: "Discord", + blurb: "very well supported right now.", + } satisfies NoteChannelPrimerChannels[number], + ] as NoteChannelPrimerChannels, + ); + } finally { + if (previousLocale === undefined) { + delete process.env.OPENCLAW_LOCALE; + } else { + process.env.OPENCLAW_LOCALE = previousLocale; + } + } + + expect(formatChannelPrimerLine).toHaveBeenCalledWith( + expect.objectContaining({ + label: "Discord", + blurb: "目前支持很完善。", + }), + ); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("入站 DM 安全默认使用配对"), + "频道工作方式", + ); + }); + it("sanitizes channel metadata before selection notes", () => { resolveChannelSetupEntries.mockReturnValue( makeChannelSetupEntries({ @@ -340,4 +439,50 @@ describe("resolveChannelSetupSelectionContributions", () => { expect(docsLink("/channels/zalo", "Docs")).toBe("https://docs.openclaw.ai/channels/zalo"); expect(lines).toEqual(["Zalo\\nBot — Setup\\nhelp"]); }); + + it("localizes built-in channel blurbs before selection notes", () => { + const previousLocale = process.env.OPENCLAW_LOCALE; + process.env.OPENCLAW_LOCALE = "zh-CN"; + resolveChannelSetupEntries.mockReturnValue( + makeChannelSetupEntries({ + entries: [ + { + id: "feishu", + meta: { + id: "feishu", + label: "Feishu", + selectionLabel: "Feishu", + docsPath: "/channels/feishu", + docsLabel: "feishu", + blurb: "飞书/Lark enterprise messaging.", + }, + }, + ], + }), + ); + + try { + const lines = resolveChannelSelectionNoteLines({ + cfg: {} as never, + installedPlugins: [], + selection: ["feishu"], + }); + + expect(formatChannelSelectionLine).toHaveBeenCalledWith( + expect.objectContaining({ + label: "Feishu", + blurb: "飞书/Lark 企业消息。", + selectionDocsPrefix: "文档:", + }), + expect.any(Function), + ); + expect(lines).toEqual(["Feishu — 飞书/Lark 企业消息。"]); + } finally { + if (previousLocale === undefined) { + delete process.env.OPENCLAW_LOCALE; + } else { + process.env.OPENCLAW_LOCALE = previousLocale; + } + } + }); }); diff --git a/src/flows/channel-setup.status.ts b/src/flows/channel-setup.status.ts index 0ddacdeffc7..d8a8fc73f59 100644 --- a/src/flows/channel-setup.status.ts +++ b/src/flows/channel-setup.status.ts @@ -24,6 +24,7 @@ import { } from "../plugins/bundled-sources.js"; import { formatDocsLink } from "../terminal/links.js"; import { sanitizeTerminalText } from "../terminal/safe-text.js"; +import { t, wizardT } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { FlowContribution } from "./types.js"; @@ -54,6 +55,33 @@ type ChannelSetupSelectionEntry = { }; }; +const CHANNEL_PRIMER_BLURB_KEYS: Record = { + clickclack: "wizard.channelsPrimer.blurbs.clickclack", + discord: "wizard.channelsPrimer.blurbs.discord", + feishu: "wizard.channelsPrimer.blurbs.feishu", + googlechat: "wizard.channelsPrimer.blurbs.googlechat", + imessage: "wizard.channelsPrimer.blurbs.imessage", + irc: "wizard.channelsPrimer.blurbs.irc", + line: "wizard.channelsPrimer.blurbs.line", + mattermost: "wizard.channelsPrimer.blurbs.mattermost", + matrix: "wizard.channelsPrimer.blurbs.matrix", + msteams: "wizard.channelsPrimer.blurbs.msteams", + "nextcloud-talk": "wizard.channelsPrimer.blurbs.nextcloudTalk", + nostr: "wizard.channelsPrimer.blurbs.nostr", + qqbot: "wizard.channelsPrimer.blurbs.qqbot", + signal: "wizard.channelsPrimer.blurbs.signal", + slack: "wizard.channelsPrimer.blurbs.slack", + "synology-chat": "wizard.channelsPrimer.blurbs.synologyChat", + telegram: "wizard.channelsPrimer.blurbs.telegram", + tlon: "wizard.channelsPrimer.blurbs.tlon", + twitch: "wizard.channelsPrimer.blurbs.twitch", + wecom: "wizard.channelsPrimer.blurbs.wecom", + whatsapp: "wizard.channelsPrimer.blurbs.whatsapp", + yuanbao: "wizard.channelsPrimer.blurbs.yuanbao", + zalo: "wizard.channelsPrimer.blurbs.zalo", + zalouser: "wizard.channelsPrimer.blurbs.zalouser", +}; + function buildChannelSetupSelectionContribution(params: { channel: ChannelChoice; label: string; @@ -132,6 +160,124 @@ function formatSetupDisplayMeta(meta: ChannelMeta): ChannelMeta { }; } +function formatChannelPrimerBlurb(channel: { id: string; blurb: string }): string { + const key = CHANNEL_PRIMER_BLURB_KEYS[channel.id]; + if (!key) { + return channel.blurb; + } + const englishBlurb = wizardT(key, undefined, { locale: "en" }); + return channel.blurb === englishBlurb ? t(key) : channel.blurb; +} + +function formatChannelSelectionMeta(meta: ChannelMeta): ChannelMeta { + return formatSetupDisplayMeta({ + ...meta, + blurb: formatChannelPrimerBlurb(meta), + selectionDocsPrefix: meta.selectionDocsPrefix ?? t("common.docs"), + }); +} + +function localizeChannelStatusLabel(label: string): string { + switch (label) { + case "configured": + return t("wizard.channels.statusConfigured"); + case "not configured": + return t("wizard.channels.statusNotConfigured"); + case "configured (plugin disabled)": + return t("wizard.channels.statusConfiguredPluginDisabled"); + case "installed": + return t("wizard.channels.statusInstalled"); + case "installed (plugin disabled)": + return t("wizard.channels.statusInstalledPluginDisabled"); + case "bundled · enable to use": + return t("wizard.channels.statusBundledEnable"); + case "install plugin to enable": + return t("wizard.channels.statusInstallPluginEnable"); + case "needs app credentials": + return t("wizard.channels.statusNeedsAppCredentials"); + case "needs app creds": + return t("wizard.channels.statusNeedsAppCreds"); + case "needs auth": + return t("wizard.channels.statusNeedsAuth"); + case "needs host + nick": + return t("wizard.channels.statusNeedsHostNick"); + case "needs private key": + return t("wizard.channels.statusNeedsPrivateKey"); + case "needs QR login": + return t("wizard.channels.statusNeedsQrLogin"); + case "needs service account": + return t("wizard.channels.statusNeedsServiceAccount"); + case "needs setup": + return t("wizard.channels.statusNeedsSetup"); + case "needs token": + return t("wizard.channels.statusNeedsToken"); + case "needs tokens": + return t("wizard.channels.statusNeedsTokens"); + case "needs token + incoming webhook": + return t("wizard.channels.statusNeedsTokenIncomingWebhook"); + case "needs token + secret": + return t("wizard.channels.statusNeedsTokenSecret"); + case "needs token + url": + return t("wizard.channels.statusNeedsTokenUrl"); + case "needs username, token, and clientId": + return t("wizard.channels.statusNeedsUsernameTokenClientId"); + case "linked": + return t("wizard.channels.statusLinked"); + case "logged in": + return t("wizard.channels.statusLoggedIn"); + case "not linked": + return t("wizard.channels.statusNotLinked"); + case "recommended · configured": + return t("wizard.channels.statusRecommendedConfigured"); + case "recommended · logged in": + return t("wizard.channels.statusRecommendedLoggedIn"); + case "recommended · newcomer-friendly": + return t("wizard.channels.statusRecommendedNewcomerFriendly"); + case "recommended · QR login": + return t("wizard.channels.statusRecommendedQrLogin"); + case "self-hosted chat": + return t("wizard.channels.statusSelfHostedChat"); + case "signal-cli found": + return t("wizard.channels.statusSignalCliFound"); + case "signal-cli missing": + return t("wizard.channels.statusSignalCliMissing"); + case "urbit messenger": + return t("wizard.channels.statusUrbitMessenger"); + case "configured (connection not verified)": + return t("wizard.channels.statusConfiguredConnectionNotVerified"); + default: + break; + } + const connectedAsPrefix = "connected as "; + if (label.startsWith(connectedAsPrefix)) { + return t("wizard.channels.statusConnectedAs", { name: label.slice(connectedAsPrefix.length) }); + } + return label; +} + +function localizeChannelStatusLine(line: string): string { + const separator = ": "; + const index = line.lastIndexOf(separator); + if (index < 0) { + return localizeChannelStatusLabel(line); + } + return `${line.slice(0, index + separator.length)}${localizeChannelStatusLabel( + line.slice(index + separator.length), + )}`; +} + +function localizeChannelSetupStatus( + status: T, +): T { + return { + ...status, + statusLines: status.statusLines.map(localizeChannelStatusLine), + ...(status.selectionHint + ? { selectionHint: localizeChannelStatusLabel(status.selectionHint) } + : {}), + }; +} + /** * Hint shown next to an installable channel option in the selection menu when * we don't yet have a runtime-collected status. Mirrors the "configured" / @@ -283,7 +429,7 @@ export async function collectChannelStatus(params: { ...fallbackStatuses, ...discoveredPluginStatuses, ...catalogStatuses, - ]; + ].map(localizeChannelSetupStatus); const mergedStatusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry])); const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines); return { @@ -311,7 +457,7 @@ export async function noteChannelStatus(params: { resolveAdapter: params.resolveAdapter, }); if (statusLines.length > 0) { - await params.prompter.note(statusLines.join("\n"), "Channel status"); + await params.prompter.note(statusLines.join("\n"), t("wizard.channels.statusTitle")); } } @@ -326,23 +472,27 @@ export async function noteChannelPrimer( label: channel.label, selectionLabel: channel.label, docsPath: "/", - blurb: channel.blurb, + blurb: formatChannelPrimerBlurb(channel), }), ), ); await prompter.note( [ - "Inbound DM safety defaults to pairing: unknown senders get a pairing code first.", - `Approve with: ${formatCliCommand("openclaw pairing approve ")}`, - 'Open/public DMs require dmPolicy="open" plus allowFrom=["*"].', - "For multi-user DMs, isolate sessions with: " + - formatCliCommand('openclaw config set session.dmScope "per-channel-peer"') + - ' (or "per-account-channel-peer" for multi-account channels).', - `Docs: ${formatDocsLink("/channels/pairing", "channels/pairing")}`, + t("wizard.channelsPrimer.inboundSafety"), + t("wizard.channelsPrimer.approveWith", { + command: formatCliCommand("openclaw pairing approve "), + }), + t("wizard.channelsPrimer.openDm"), + t("wizard.channelsPrimer.multiUserDm", { + command: formatCliCommand('openclaw config set session.dmScope "per-channel-peer"'), + }), + t("wizard.channelsPrimer.docs", { + link: formatDocsLink("/channels/pairing", "channels/pairing"), + }), "", ...channelLines, ].join("\n"), - "How channels work", + t("wizard.channelsPrimer.title"), ); } @@ -375,7 +525,7 @@ export function resolveChannelSelectionNoteLines(params: { for (const entry of entries) { selectionNotes.set( entry.id, - formatChannelSelectionLine(formatSetupDisplayMeta(entry.meta), formatDocsLink), + formatChannelSelectionLine(formatChannelSelectionMeta(entry.meta), formatDocsLink), ); } return params.selection diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index fe1b40ccb4d..597337d4b70 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -35,6 +35,7 @@ import { resolveBundledPluginSources } from "../plugins/bundled-sources.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; +import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { maybeConfigureDmPolicies, @@ -232,13 +233,13 @@ export async function setupChannels( }); const { statusByChannel, statusLines } = statusSummary; if (!options?.skipStatusNote && statusLines.length > 0) { - await prompter.note(statusLines.join("\n"), "Channel status"); + await prompter.note(statusLines.join("\n"), t("wizard.channels.statusTitle")); } const shouldConfigure = options?.skipConfirm ? true : await prompter.confirm({ - message: "Set up a chat channel now?", + message: t("wizard.channels.setupConfirm"), initialValue: true, }); if (!shouldConfigure) { @@ -373,10 +374,12 @@ export async function setupChannels( const disabledHint = resolveConfigDisabledHint(channel); if (disabledHint) { await prompter.note( - `${channel} cannot be configured while ${disabledHint}. Enable it, then run ${formatCliCommand( - "openclaw channels add", - )} again.`, - "Channel setup", + t("wizard.channels.disabledDuringSetup", { + channel, + hint: disabledHint, + command: formatCliCommand("openclaw channels add"), + }), + t("wizard.channels.setupTitle"), ); return false; } @@ -384,10 +387,12 @@ export async function setupChannels( next = result.config; if (!result.enabled) { await prompter.note( - `Cannot enable ${channel}: ${result.reason ?? "plugin disabled"}. Run ${formatCliCommand( - "openclaw plugins list", - )} to inspect plugin state.`, - "Channel setup", + t("wizard.channels.pluginEnableFailed", { + channel, + reason: result.reason ?? "plugin disabled", + command: formatCliCommand("openclaw plugins list"), + }), + t("wizard.channels.setupTitle"), ); return false; } @@ -396,15 +401,20 @@ export async function setupChannels( if (!plugin) { if (adapter) { await prompter.note( - `${channel} plugin not available (continuing with setup). If the channel still doesn't work after setup, run \`${formatCliCommand( - "openclaw plugins list", - )}\` and \`${formatCliCommand("openclaw plugins enable " + channel)}\`, then restart the gateway.`, - "Channel setup", + t("wizard.channels.pluginMissingRecoverable", { + channel, + listCommand: formatCliCommand("openclaw plugins list"), + enableCommand: formatCliCommand("openclaw plugins enable " + channel), + }), + t("wizard.channels.setupTitle"), ); await refreshStatus(channel); return true; } - await prompter.note(`${channel} plugin not available.`, "Channel setup"); + await prompter.note( + t("wizard.channels.pluginNotAvailable", { channel }), + t("wizard.channels.setupTitle"), + ); return false; } await refreshStatus(channel); @@ -452,10 +462,11 @@ export async function setupChannels( const adapter = getVisibleSetupFlowAdapter(channel); if (!adapter) { await prompter.note( - `${channel} does not have an interactive setup screen yet. Run ${formatCliCommand( - `openclaw channels add --channel ${channel} --help`, - )} for supported flags.`, - "Channel setup", + t("wizard.channels.noInteractiveSetup", { + channel, + command: formatCliCommand(`openclaw channels add --channel ${channel} --help`), + }), + t("wizard.channels.setupTitle"), ); return; } @@ -514,7 +525,10 @@ export async function setupChannels( } if (action === "delete" && !supportsDelete) { - await prompter.note(`${label} does not support deleting config entries.`, "Remove channel"); + await prompter.note( + t("wizard.channels.configuredDeleteUnsupported", { label }), + t("wizard.channels.removeTitle"), + ); return; } @@ -538,7 +552,7 @@ export async function setupChannels( if (action === "delete") { const confirmed = await prompter.confirm({ - message: `Delete ${label} account "${accountLabel}"?`, + message: t("wizard.channels.deleteAccount", { label, account: accountLabel }), initialValue: false, }); if (!confirmed) { @@ -574,8 +588,11 @@ export async function setupChannels( : undefined; if (deferredDisabledHint) { await prompter.note( - `${channel} cannot be configured while ${deferredDisabledHint}. Enable it before setup.`, - "Channel setup", + t("wizard.channels.disabledBeforeSetup", { + channel, + hint: deferredDisabledHint, + }), + t("wizard.channels.setupTitle"), ); return "done"; } @@ -612,8 +629,8 @@ export async function setupChannels( const disabledHint = resolveConfigDisabledHint(channel); if (disabledHint) { await prompter.note( - `${channel} cannot be configured while ${disabledHint}. Enable it before setup.`, - "Channel setup", + t("wizard.channels.disabledBeforeSetup", { channel, hint: disabledHint }), + t("wizard.channels.setupTitle"), ); return "done"; } @@ -636,7 +653,10 @@ export async function setupChannels( ); } if (!plugin) { - await prompter.note(`${channel} plugin not available.`, "Channel setup"); + await prompter.note( + t("wizard.channels.pluginNotAvailable", { channel }), + t("wizard.channels.setupTitle"), + ); return "done"; } await refreshStatus(channel); @@ -664,8 +684,8 @@ export async function setupChannels( const disabledHint = resolveConfigDisabledHint(channel); if (disabledHint) { await prompter.note( - `${channel} cannot be configured while ${disabledHint}. Enable it before setup.`, - "Channel setup", + t("wizard.channels.disabledBeforeSetup", { channel, hint: disabledHint }), + t("wizard.channels.setupTitle"), ); return "done"; } @@ -726,7 +746,7 @@ export async function setupChannels( while (true) { const { entries, catalogById } = getChannelEntries(); const choice = await prompter.select({ - message: "Select channel (QuickStart)", + message: t("wizard.channels.selectQuickstart"), options: [ ...resolveChannelSetupSelectionContributions({ entries, @@ -735,8 +755,10 @@ export async function setupChannels( }).map((contribution) => contribution.option), { value: "__skip__", - label: "Skip for now", - hint: `You can add channels later via \`${formatCliCommand("openclaw channels add")}\``, + label: t("common.skipForNow"), + hint: t("wizard.channels.skipLaterHint", { + command: formatCliCommand("openclaw channels add"), + }), }, ], initialValue: quickstartDefault, @@ -755,7 +777,7 @@ export async function setupChannels( while (true) { const { entries, catalogById } = getChannelEntries(); const choice = await prompter.select({ - message: "Select a channel", + message: t("wizard.channels.select"), options: [ ...resolveChannelSetupSelectionContributions({ entries, @@ -764,8 +786,8 @@ export async function setupChannels( }).map((contribution) => contribution.option), { value: doneValue, - label: "Finished", - hint: selection.length > 0 ? "Done" : "Skip for now", + label: t("common.finished"), + hint: selection.length > 0 ? t("wizard.channels.doneHint") : t("common.skipForNow"), }, ], initialValue, @@ -785,7 +807,7 @@ export async function setupChannels( selection, }); if (selectedLines.length > 0) { - await prompter.note(selectedLines.join("\n"), "Selected channels"); + await prompter.note(selectedLines.join("\n"), t("wizard.channels.selectedTitle")); } if (!options?.skipDmPolicyPrompt) { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 62e20efad30..b1cb0a084c4 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -32,6 +32,7 @@ import type { ProviderPlugin } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; export { applyPrimaryModel } from "../plugins/provider-model-primary.js"; @@ -45,6 +46,16 @@ const EMPTY_LITERAL_PREFIX_PROVIDERS = new Set(); // Internal router models are valid defaults during auth/setup but not manual API targets. const HIDDEN_ROUTER_MODELS = new Set(["openrouter/auto"]); +function formatKeepCurrentModelLabel(params: { + configuredRaw?: string; + configuredLabel: string; + resolvedKey: string; +}): string { + return params.configuredRaw + ? t("wizard.model.keepCurrent", { value: params.configuredLabel }) + : t("wizard.model.keepCurrentDefault", { value: params.resolvedKey }); +} + export type PromptDefaultModelParams = { config: OpenClawConfig; prompter: WizardPrompter; @@ -360,12 +371,14 @@ async function promptManualModel(params: { initialValue?: string; }): Promise { const modelInput = await params.prompter.text({ - message: params.allowBlank ? "Default model (blank to keep)" : "Default model", + message: params.allowBlank + ? t("wizard.model.defaultModelBlankToKeep") + : t("wizard.model.defaultModel"), initialValue: params.initialValue, placeholder: "provider/model", validate: params.allowBlank ? undefined - : (value) => (normalizeOptionalString(value) ? undefined : "Required"), + : (value) => (normalizeOptionalString(value) ? undefined : t("common.required")), }); const model = (modelInput ?? "").trim(); if (!model) { @@ -385,7 +398,7 @@ function buildModelProviderFilterOptions( return { value: provider, label: provider, - hint: `${count} model${count === 1 ? "" : "s"}`, + hint: t("wizard.model.modelCount", { count, plural: count === 1 ? "" : "s" }), }; }); } @@ -421,8 +434,11 @@ async function maybeFilterModelsByProvider(params: { : undefined; if (shouldPromptProvider) { const selection = await params.prompter.select({ - message: "Filter models by provider", - options: [{ value: "*", label: "All providers" }, ...buildModelProviderFilterOptions(next)], + message: t("wizard.model.filterByProvider"), + options: [ + { value: "*", label: t("wizard.model.allProviders") }, + ...buildModelProviderFilterOptions(next), + ], searchable: true, }); if (selection !== "*") { @@ -499,8 +515,8 @@ async function maybeHandleProviderPluginSelection(params: { } if (!params.agentDir || !params.runtime) { await params.prompter.note( - "Provider setup requires agent and runtime context.", - "Provider setup unavailable", + t("wizard.model.providerSetupUnavailable"), + t("wizard.model.providerSetupUnavailableTitle"), ); return {}; } @@ -603,24 +619,24 @@ export async function promptDefaultModel( const options: WizardSelectOption[] = [ { value: KEEP_VALUE, - label: configuredRaw - ? `Keep current (${configuredLabel})` - : `Keep current (default: ${resolvedKey})`, + label: formatKeepCurrentModelLabel({ configuredRaw, configuredLabel, resolvedKey }), hint: - configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined, + configuredRaw && configuredRaw !== resolvedKey + ? t("wizard.model.resolvesTo", { value: resolvedKey }) + : undefined, }, ]; if (includeManual) { - options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); + options.push({ value: MANUAL_VALUE, label: t("wizard.model.enterManually") }); } options.push({ value: BROWSE_VALUE, - label: "Browse all models", - hint: "loads provider catalogs", + label: t("wizard.model.browseAll"), + hint: t("wizard.model.loadsProviderCatalogs"), }); const selection = await params.prompter.select({ - message: params.message ?? "Default model", + message: params.message ?? t("wizard.model.defaultModel"), options, initialValue: KEEP_VALUE, searchable: false, @@ -646,21 +662,21 @@ export async function promptDefaultModel( if (allowKeep) { options.push({ value: KEEP_VALUE, - label: configuredRaw - ? `Keep current (${configuredLabel})` - : `Keep current (default: ${resolvedKey})`, + label: formatKeepCurrentModelLabel({ configuredRaw, configuredLabel, resolvedKey }), hint: - configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined, + configuredRaw && configuredRaw !== resolvedKey + ? t("wizard.model.resolvesTo", { value: resolvedKey }) + : undefined, }); } if (includeManual) { - options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); + options.push({ value: MANUAL_VALUE, label: t("wizard.model.enterManually") }); } if (configuredKey && !options.some((option) => option.value === configuredKey)) { options.push({ value: configuredKey, label: configuredKey, - hint: "current", + hint: t("wizard.model.current"), }); } if (options.length === 0) { @@ -671,7 +687,7 @@ export async function promptDefaultModel( }); } const selection = await params.prompter.select({ - message: params.message ?? "Default model", + message: params.message ?? t("wizard.model.defaultModel"), options, initialValue: allowKeep ? KEEP_VALUE : configuredKey || MANUAL_VALUE, searchable: false, @@ -689,7 +705,7 @@ export async function promptDefaultModel( return { model: selection }; } - const catalogProgress = params.prompter.progress("Loading available models"); + const catalogProgress = params.prompter.progress(t("wizard.model.loadingModels")); let catalog: Awaited>; try { catalog = await loadPickerModelCatalog(cfg); @@ -772,13 +788,11 @@ export async function promptDefaultModel( if (allowKeep) { options.push({ value: KEEP_VALUE, - label: configuredRaw - ? `Keep current (${configuredLabel})` - : `Keep current (default: ${resolvedKey})`, + label: formatKeepCurrentModelLabel({ configuredRaw, configuredLabel, resolvedKey }), }); } if (includeManual) { - options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); + options.push({ value: MANUAL_VALUE, label: t("wizard.model.enterManually") }); } if (includeProviderPluginSetups && params.agentDir) { options.push( @@ -805,7 +819,7 @@ export async function promptDefaultModel( options.push({ value: configuredKey, label: configuredLabel, - hint: "current (not in catalog)", + hint: t("wizard.model.currentNotInCatalog"), }); } @@ -823,7 +837,7 @@ export async function promptDefaultModel( } const selection = await params.prompter.select({ - message: params.message ?? "Default model", + message: params.message ?? t("wizard.model.defaultModel"), options, initialValue, searchable: true, @@ -957,14 +971,15 @@ export async function promptModelAllowlist(params: { seen, aliasIndex, hasAuth, - fallbackHint: allowedKeys.length > 0 ? "allowed" : "configured", + fallbackHint: + allowedKeys.length > 0 ? t("wizard.model.allowed") : t("wizard.model.configured"), }); } if (options.length === 0) { return {}; } const selection = await params.prompter.multiselect({ - message: params.message ?? "Models in /model picker (multi-select)", + message: params.message ?? t("wizard.model.allowlistPicker"), options, initialValues: initialKeys.length > 0 ? initialKeys : undefined, searchable: true, @@ -974,7 +989,7 @@ export async function promptModelAllowlist(params: { return { models: selected, scopeKeys }; } const confirmScopedClear = await params.prompter.confirm({ - message: "Remove these provider models from the /model picker?", + message: t("wizard.model.removeProviderModels"), initialValue: false, }); if (!confirmScopedClear) { @@ -987,7 +1002,7 @@ export async function promptModelAllowlist(params: { return {}; } - const allowlistProgress = params.prompter.progress("Loading available models"); + const allowlistProgress = params.prompter.progress(t("wizard.model.loadingModels")); let catalog: Awaited>; try { catalog = await loadPickerModelCatalog(cfg, { preferredProvider }); @@ -1010,9 +1025,7 @@ export async function promptModelAllowlist(params: { const noCatalogInitialKeys = existingKeys.length > 0 ? normalizeModelKeys([...existingKeys, ...fallbackKeys]) : []; const raw = await params.prompter.text({ - message: - params.message ?? - "Allowlist models (comma-separated provider/model; blank to keep current)", + message: params.message ?? t("wizard.model.allowlistText"), initialValue: noCatalogInitialKeys.join(", "), placeholder: "provider/model, other-provider/model", }); @@ -1079,7 +1092,9 @@ export async function promptModelAllowlist(params: { options.push({ value: key, label: key, - hint: allowedKeySet ? "allowed (not in catalog)" : "configured (not in catalog)", + hint: allowedKeySet + ? t("wizard.model.allowedNotInCatalog") + : t("wizard.model.configuredNotInCatalog"), }); seen.add(key); } @@ -1088,7 +1103,7 @@ export async function promptModelAllowlist(params: { } const selection = await params.prompter.multiselect({ - message: params.message ?? "Models in /model picker (multi-select)", + message: params.message ?? t("wizard.model.allowlistPicker"), options, initialValues: initialKeys.length > 0 ? initialKeys : undefined, searchable: true, @@ -1099,7 +1114,7 @@ export async function promptModelAllowlist(params: { } if (scopeKeys) { const confirmScopedClear = await params.prompter.confirm({ - message: "Remove these provider models from the /model picker?", + message: t("wizard.model.removeProviderModels"), initialValue: false, }); if (!confirmScopedClear) { @@ -1111,7 +1126,7 @@ export async function promptModelAllowlist(params: { return { models: [] }; } const confirmClear = await params.prompter.confirm({ - message: "Clear the model allowlist? (shows all models)", + message: t("wizard.model.clearAllowlist"), initialValue: false, }); if (!confirmClear) { diff --git a/src/flows/search-setup.test.ts b/src/flows/search-setup.test.ts index b7e25e0f01d..25b8d9f4887 100644 --- a/src/flows/search-setup.test.ts +++ b/src/flows/search-setup.test.ts @@ -159,6 +159,44 @@ describe("runSearchSetupFlow", () => { ensureOnboardingPluginInstalled.mockClear(); }); + it("localizes setup copy for web search provider selection", async () => { + const previousLocale = process.env.OPENCLAW_LOCALE; + process.env.OPENCLAW_LOCALE = "zh-CN"; + const note = vi.fn(async () => {}); + const select = vi.fn().mockResolvedValueOnce("__skip__"); + const prompter = createWizardPrompter({ + note: note as never, + select: select as never, + }); + + try { + await runSearchSetupFlow( + { plugins: { allow: ["xai"] } }, + createNonExitingRuntime(), + prompter, + ); + } finally { + if (previousLocale === undefined) { + delete process.env.OPENCLAW_LOCALE; + } else { + process.env.OPENCLAW_LOCALE = previousLocale; + } + } + + expect(note).toHaveBeenCalledWith(expect.stringContaining("在线查询资料"), "网页搜索"); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "搜索提供方", + options: expect.arrayContaining([ + expect.objectContaining({ + label: "暂时跳过", + hint: "稍后可用 openclaw configure --section web 配置", + }), + ]), + }), + ); + }); + it("runs provider-owned setup after selecting Grok web search", async () => { const select = vi .fn() diff --git a/src/flows/search-setup.ts b/src/flows/search-setup.ts index b245d69bfc2..18fe24873c0 100644 --- a/src/flows/search-setup.ts +++ b/src/flows/search-setup.ts @@ -18,6 +18,7 @@ import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers import { sortWebSearchProviders } from "../plugins/web-search-providers.shared.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { FlowContribution, FlowOption } from "./types.js"; import { sortFlowContributionsByLabel } from "./types.js"; @@ -41,6 +42,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"; type SearchProviderEntryWithInstall = PluginWebSearchProviderEntry & { [SEARCH_INSTALL_CATALOG_ENTRY]?: WebSearchInstallCatalogEntry; @@ -390,22 +392,22 @@ export async function runSearchSetupFlow( if (providerOptions.length === 0) { await prompter.note( [ - "No web search providers are currently available under this plugin policy.", - "Enable plugins or remove deny rules, then run setup again.", - "Docs: https://docs.openclaw.ai/tools/web", + t("wizard.search.noProvidersByPolicy"), + t("wizard.search.noProvidersAction"), + t("wizard.search.docsLine", { url: WEB_SEARCH_DOCS_URL }), ].join("\n"), - "Web search", + t("wizard.search.title"), ); return config; } await prompter.note( [ - "Web search lets your agent look things up online.", - "Choose a provider. Some providers need an API key, and some work key-free.", - "Docs: https://docs.openclaw.ai/tools/web", + t("wizard.search.intro"), + t("wizard.search.chooseProvider"), + t("wizard.search.docsLine", { url: WEB_SEARCH_DOCS_URL }), ].join("\n"), - "Web search", + t("wizard.search.title"), ); const existingProvider = config.tools?.web?.search?.provider; @@ -413,9 +415,9 @@ export async function runSearchSetupFlow( const options = providerOptions.map((entry) => { const hint = entry.requiresCredential === false - ? `${entry.hint} · key-free` + ? `${entry.hint} · ${t("wizard.search.keyFree")}` : providerIsReady(config, entry) - ? `${entry.hint} · configured` + ? `${entry.hint} · ${t("wizard.search.configured")}` : entry.hint; return { value: entry.id, label: entry.label, hint }; }); @@ -432,13 +434,13 @@ export async function runSearchSetupFlow( })(); const choice = await prompter.select({ - message: "Search provider", + message: t("wizard.search.providerPrompt"), options: [ ...options, { value: "__skip__" as const, - label: "Skip for now", - hint: "Configure later with openclaw configure --section web", + label: t("common.skipForNow"), + hint: t("wizard.search.configureLaterHint"), }, ], initialValue: defaultProvider, diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index a4adfd87b1b..60909a4c863 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -10,6 +10,7 @@ import { normalizeAgentModelRefForConfig } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; import { sanitizeTerminalText } from "../terminal/safe-text.js"; +import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { enablePluginInConfig } from "./enable.js"; import { @@ -119,13 +120,19 @@ async function noteDefaultModelResult(params: { params.previousPrimary !== params.selectedModel ) { await params.prompter.note( - `Kept existing default model ${params.previousPrimary}; ${selectedModelDisplay} is available.`, - "Model configured", + t("wizard.model.keptExistingDefault", { + current: params.previousPrimary, + selected: selectedModelDisplay, + }), + t("wizard.model.configuredTitle"), ); return; } - await params.prompter.note(`Default model set to ${selectedModelDisplay}`, "Model configured"); + await params.prompter.note( + t("wizard.model.defaultSet", { model: selectedModelDisplay }), + t("wizard.model.configuredTitle"), + ); } async function applyDefaultModelFromAuthChoice(params: { @@ -568,8 +575,11 @@ export async function applyAuthChoicePluginProvider( } if (params.agentId) { await params.prompter.note( - `Default model set to ${selectedModelDisplay} for agent "${params.agentId}".`, - "Model configured", + t("wizard.model.defaultSetForAgent", { + agent: params.agentId, + model: selectedModelDisplay, + }), + t("wizard.model.configuredTitle"), ); } nextConfig = restoreConfiguredPrimaryModel(nextConfig, params.config); diff --git a/src/wizard/setup.completion.test.ts b/src/wizard/setup.completion.test.ts index 4086d745c07..b2b51642cd5 100644 --- a/src/wizard/setup.completion.test.ts +++ b/src/wizard/setup.completion.test.ts @@ -1,6 +1,20 @@ import { describe, expect, it, vi } from "vitest"; import { setupWizardShellCompletion } from "./setup.completion.js"; +async function withLocale(locale: string, run: () => Promise): Promise { + const previousLocale = process.env.OPENCLAW_LOCALE; + process.env.OPENCLAW_LOCALE = locale; + try { + await run(); + } finally { + if (previousLocale === undefined) { + delete process.env.OPENCLAW_LOCALE; + } else { + process.env.OPENCLAW_LOCALE = previousLocale; + } + } +} + function createPrompter(confirmValue = false) { return { confirm: vi.fn(async () => confirmValue), @@ -48,4 +62,23 @@ describe("setupWizardShellCompletion", () => { expect(deps.installCompletion).not.toHaveBeenCalled(); expect(prompter.note).not.toHaveBeenCalled(); }); + + it("localizes advanced prompts and install notes", async () => { + await withLocale("zh-CN", async () => { + const prompter = createPrompter(true); + const deps = createDeps(); + + await setupWizardShellCompletion({ flow: "advanced", prompter, deps }); + + expect(prompter.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: "为 openclaw 启用 zsh shell completion?", + }), + ); + expect(prompter.note).toHaveBeenCalledWith( + "Shell completion 已安装。重启 shell 或运行:source ~/.zshrc", + "Shell completion", + ); + }); + }); }); diff --git a/src/wizard/setup.completion.ts b/src/wizard/setup.completion.ts index 46ee5ba61b8..3b91cbea885 100644 --- a/src/wizard/setup.completion.ts +++ b/src/wizard/setup.completion.ts @@ -8,6 +8,7 @@ import { ensureCompletionCacheExists, } from "../commands/doctor-completion.js"; import { pathExists } from "../utils.js"; +import { t } from "./i18n/index.js"; import type { WizardPrompter } from "./prompts.js"; import type { WizardFlow } from "./setup.types.js"; @@ -36,9 +37,9 @@ async function resolveProfileHint(shell: ShellCompletionStatus["shell"]): Promis function formatReloadHint(shell: ShellCompletionStatus["shell"], profileHint: string): string { if (shell === "powershell") { - return "Restart your shell (or reload your PowerShell profile)."; + return t("wizard.completion.reloadPowerShell"); } - return `Restart your shell or run: source ${profileHint}`; + return t("wizard.completion.reloadShell", { profile: profileHint }); } export async function setupWizardShellCompletion(params: { @@ -78,7 +79,10 @@ export async function setupWizardShellCompletion(params: { params.flow === "quickstart" ? true : await params.prompter.confirm({ - message: `Enable ${completionStatus.shell} shell completion for ${cliName}?`, + message: t("wizard.completion.enable", { + shell: completionStatus.shell, + cli: cliName, + }), initialValue: true, }); @@ -90,8 +94,8 @@ export async function setupWizardShellCompletion(params: { const cacheGenerated = await deps.ensureCompletionCacheExists(cliName); if (!cacheGenerated) { await params.prompter.note( - `Failed to generate completion cache. Run \`${cliName} completion --install\` later.`, - "Shell completion", + t("wizard.completion.cacheFailed", { command: `${cliName} completion --install` }), + t("wizard.completion.title"), ); return; } @@ -101,8 +105,10 @@ export async function setupWizardShellCompletion(params: { const profileHint = await resolveProfileHint(completionStatus.shell); await params.prompter.note( - `Shell completion installed. ${formatReloadHint(completionStatus.shell, profileHint)}`, - "Shell completion", + t("wizard.completion.installed", { + reloadHint: formatReloadHint(completionStatus.shell, profileHint), + }), + t("wizard.completion.title"), ); } // Case 4: Both profile and cache exist (using cached version) - all good, nothing to do diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index ae88d80a776..5a0f92ec772 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -424,6 +424,61 @@ describe("finalizeSetupWizard", () => { }); }); + it("localizes the bootstrap hatch TUI seed message", async () => { + const previousLocale = process.env.OPENCLAW_LOCALE; + process.env.OPENCLAW_LOCALE = "zh-CN"; + vi.spyOn(fs, "access").mockResolvedValueOnce(undefined); + const select = vi.fn(async (params: { message: string }) => { + if (params.message === "你想如何启动 agent?") { + return "tui"; + } + return "later"; + }); + const prompter = buildWizardPrompter({ + select: select as never, + confirm: vi.fn(async () => false), + }); + + try { + 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(launchTuiCli).toHaveBeenCalledWith({ + local: true, + deliver: false, + message: "醒醒,我的朋友!", + timeoutMs: 300_000, + }); + } finally { + if (previousLocale === undefined) { + delete process.env.OPENCLAW_LOCALE; + } else { + process.env.OPENCLAW_LOCALE = previousLocale; + } + } + }); + 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 }) => { @@ -559,10 +614,35 @@ describe("finalizeSetupWizard", () => { expect(gatewayServiceRestart).toHaveBeenCalledTimes(1); expect(gatewayServiceInstall).not.toHaveBeenCalled(); expect(gatewayServiceUninstall).not.toHaveBeenCalled(); - expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…"); + expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service..."); expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled."); }); + it("localizes finalize non-prompt notes", async () => { + const previousLocale = process.env.OPENCLAW_LOCALE; + process.env.OPENCLAW_LOCALE = "zh-CN"; + const prompter = createLaterPrompter(); + + try { + await finalizeSetupWizard(createAdvancedFinalizeArgs({ prompter })); + } finally { + if (previousLocale === undefined) { + delete process.env.OPENCLAW_LOCALE; + } else { + process.env.OPENCLAW_LOCALE = previousLocale; + } + } + + const noteMessages = (prompter.note as ReturnType).mock.calls.map((call) => + String(call[0]), + ); + expect(noteMessages.some((message) => message.includes("备份你的 agent 工作区"))).toBe(true); + expect( + noteMessages.some((message) => message.includes("在你的电脑上运行 agent 存在风险")), + ).toBe(true); + expect(noteMessages.some((message) => message.includes("已跳过 web search"))).toBe(true); + }); + it("reports selected providers blocked by plugin policy as unavailable", async () => { const prompter = createLaterPrompter(); diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index febb2c89769..744188e2f12 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -33,6 +33,7 @@ import { restoreTerminalState } from "../terminal/restore.js"; import { launchTuiCli } from "../tui/tui-launch.js"; import { resolveUserPath } from "../utils.js"; import { listConfiguredWebSearchProviders } from "../web-search/runtime.js"; +import { t } from "./i18n/index.js"; import type { WizardPrompter } from "./prompts.js"; import { setupWizardShellCompletion } from "./setup.completion.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; @@ -54,6 +55,17 @@ type OnboardSearchModule = typeof import("../commands/onboard-search.js"); let onboardSearchModulePromise: Promise | undefined; const HATCH_TUI_TIMEOUT_MS = 5 * 60 * 1000; +function getLocalizedGatewayDaemonRuntimeOptions() { + return GATEWAY_DAEMON_RUNTIME_OPTIONS.map((option) => ({ + hint: + option.value === "node" + ? t("wizard.finalize.daemonRuntimeNodeHint") + : (option.hint ?? undefined), + label: option.value === "node" ? t("wizard.finalize.daemonRuntimeNode") : option.label, + value: option.value, + })); +} + function loadOnboardSearchModule(): Promise { onboardSearchModulePromise ??= import("../commands/onboard-search.js"); return onboardSearchModulePromise; @@ -84,10 +96,7 @@ export async function finalizeSetupWizard( const systemdAvailable = process.platform === "linux" ? await isSystemdUserServiceAvailable() : true; if (process.platform === "linux" && !systemdAvailable) { - await prompter.note( - "Systemd user services are unavailable. Skipping lingering checks and service install.", - "Systemd", - ); + await prompter.note(t("wizard.finalize.systemdUnavailable"), "Systemd"); } if (process.platform === "linux" && systemdAvailable) { @@ -98,8 +107,7 @@ export async function finalizeSetupWizard( confirm: prompter.confirm, note: prompter.note, }, - reason: - "Linux installs use a systemd user service by default. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.", + reason: t("wizard.finalize.systemdLingerReason"), requireConfirm: false, }); } @@ -115,15 +123,15 @@ export async function finalizeSetupWizard( installDaemon = true; } else { installDaemon = await prompter.confirm({ - message: "Install Gateway service (recommended)", + message: t("wizard.finalize.installGateway"), initialValue: true, }); } if (process.platform === "linux" && !systemdAvailable && installDaemon) { await prompter.note( - "Systemd user services are unavailable; skipping service install. Use your container supervisor or `docker compose up -d`.", - "Gateway service", + t("wizard.finalize.systemdInstallSkipped"), + t("wizard.finalize.gatewayService"), ); installDaemon = false; } @@ -133,14 +141,14 @@ export async function finalizeSetupWizard( flow === "quickstart" ? DEFAULT_GATEWAY_DAEMON_RUNTIME : await prompter.select({ - message: "Gateway service runtime", - options: GATEWAY_DAEMON_RUNTIME_OPTIONS, + message: t("wizard.finalize.daemonRuntime"), + options: getLocalizedGatewayDaemonRuntimeOptions(), initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME, }); if (flow === "quickstart") { await prompter.note( - "QuickStart uses Node for the Gateway service (stable + supported).", - "Gateway service runtime", + t("wizard.finalize.quickstartNodeRuntime"), + t("wizard.finalize.daemonRuntime"), ); } const service = resolveGatewayService(); @@ -148,35 +156,37 @@ export async function finalizeSetupWizard( let restartWasScheduled = false; if (loaded) { const action = await prompter.select({ - message: "Gateway service already installed", + message: t("wizard.finalize.alreadyInstalled"), options: [ - { value: "restart", label: "Restart" }, - { value: "reinstall", label: "Reinstall" }, - { value: "skip", label: "Skip" }, + { value: "restart", label: t("wizard.finalize.restart") }, + { value: "reinstall", label: t("wizard.finalize.reinstall") }, + { value: "skip", label: t("common.skip") }, ], }); if (action === "restart") { - let restartDoneMessage = "Gateway service restarted."; + let restartDoneMessage = t("wizard.finalize.gatewayServiceRestarted"); await withWizardProgress( - "Gateway service", + t("wizard.finalize.gatewayService"), { doneMessage: () => restartDoneMessage }, async (progress) => { - progress.update("Restarting Gateway service…"); + progress.update(t("wizard.finalize.gatewayServiceRestarting")); const restartResult = await service.restart({ env: process.env, stdout: process.stdout, }); const restartStatus = describeGatewayServiceRestart("Gateway", restartResult); - restartDoneMessage = restartStatus.progressMessage; + restartDoneMessage = restartStatus.scheduled + ? t("wizard.finalize.gatewayServiceRestartScheduled") + : t("wizard.finalize.gatewayServiceRestarted"); restartWasScheduled = restartStatus.scheduled; }, ); } else if (action === "reinstall") { await withWizardProgress( - "Gateway service", - { doneMessage: "Gateway service uninstalled." }, + t("wizard.finalize.gatewayService"), + { doneMessage: t("wizard.finalize.gatewayServiceUninstalled") }, async (progress) => { - progress.update("Uninstalling Gateway service…"); + progress.update(t("wizard.finalize.gatewayServiceUninstalling")); await service.uninstall({ env: process.env, stdout: process.stdout }); }, ); @@ -187,10 +197,10 @@ export async function finalizeSetupWizard( !loaded || (!restartWasScheduled && loaded && !(await service.isLoaded({ env: process.env }))) ) { - const progress = prompter.progress("Gateway service"); + const progress = prompter.progress(t("wizard.finalize.gatewayService")); let installError: string | null = null; try { - progress.update("Preparing Gateway service…"); + progress.update(t("wizard.finalize.gatewayServicePreparing")); const tokenResolution = await resolveGatewayInstallToken({ config: nextConfig, env: process.env, @@ -200,9 +210,9 @@ export async function finalizeSetupWizard( } if (tokenResolution.unavailableReason) { installError = [ - "Gateway install blocked:", + t("wizard.finalize.gatewayInstallBlocked"), tokenResolution.unavailableReason, - "Fix gateway auth config/token input and rerun setup.", + t("wizard.finalize.gatewayInstallFixAuth"), ].join(" "); } else { const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan( @@ -215,7 +225,7 @@ export async function finalizeSetupWizard( }, ); - progress.update("Installing Gateway service…"); + progress.update(t("wizard.finalize.gatewayServiceInstalling")); await service.install({ env: process.env, stdout: process.stdout, @@ -228,11 +238,16 @@ export async function finalizeSetupWizard( installError = formatErrorMessage(err); } finally { progress.stop( - installError ? "Gateway service install failed." : "Gateway service installed.", + installError + ? t("wizard.finalize.gatewayServiceInstallFailed") + : t("wizard.finalize.gatewayServiceInstalled"), ); } if (installError) { - await prompter.note(`Gateway service install failed: ${installError}`, "Gateway"); + await prompter.note( + t("wizard.finalize.gatewayServiceInstallFailedWithError", { error: installError }), + "Gateway", + ); await prompter.note(gatewayInstallErrorHint(), "Gateway"); } } @@ -250,10 +265,10 @@ export async function finalizeSetupWizard( } catch (error) { await prompter.note( [ - "Could not resolve gateway.auth.password SecretRef for setup auth.", + t("wizard.finalize.secretRefAuthFailed", { field: "gateway.auth.password" }), formatErrorMessage(error), ].join("\n"), - "Gateway auth", + t("wizard.gateway.auth"), ); } } @@ -303,11 +318,11 @@ export async function finalizeSetupWizard( runtime.error(formatHealthCheckFailure(err)); await prompter.note( [ - "Docs:", + t("common.docs"), "https://docs.openclaw.ai/gateway/health", "https://docs.openclaw.ai/gateway/troubleshooting", ].join("\n"), - "Health check help", + t("wizard.finalize.healthCheckHelp"), ); } } else if (installDaemon) { @@ -320,20 +335,26 @@ export async function finalizeSetupWizard( ); await prompter.note( [ - "Docs:", + t("common.docs"), "https://docs.openclaw.ai/gateway/health", "https://docs.openclaw.ai/gateway/troubleshooting", ].join("\n"), - "Health check help", + t("wizard.finalize.healthCheckHelp"), ); } else { await prompter.note( [ - "Gateway not detected yet.", - "Setup was run without Gateway service install, so no background gateway is expected.", - `Start now: ${formatCliCommand("openclaw gateway run")}`, - `Or rerun with: ${formatCliCommand("openclaw onboard --install-daemon")}`, - `Or skip this probe next time: ${formatCliCommand("openclaw onboard --skip-health")}`, + 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", ); @@ -351,12 +372,12 @@ export async function finalizeSetupWizard( await prompter.note( [ - "Add nodes for extra features:", - "- macOS app (system + notifications)", - "- iOS app (camera/canvas)", - "- Android app (camera/canvas)", + t("wizard.finalize.addNodes"), + `- ${t("wizard.finalize.nodeMac")}`, + `- ${t("wizard.finalize.nodeIos")}`, + `- ${t("wizard.finalize.nodeAndroid")}`, ].join("\n"), - "Optional apps", + t("wizard.finalize.optionalApps"), ); const controlUiBasePath = @@ -380,8 +401,10 @@ export async function finalizeSetupWizard( }); } const gatewayStatusLine = gatewayProbe.ok - ? "Gateway: reachable" - : `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`; + ? t("wizard.finalize.gatewayReachable") + : t("wizard.finalize.gatewayNotDetectedStatus", { + detail: gatewayProbe.detail ? ` (${gatewayProbe.detail})` : "", + }); const bootstrapPath = path.join( resolveUserPath(options.workspaceDir), DEFAULT_BOOTSTRAP_FILENAME, @@ -393,13 +416,13 @@ export async function finalizeSetupWizard( await prompter.note( [ - `Web UI: ${links.httpUrl}`, + t("wizard.finalize.webUiUrl", { url: links.httpUrl }), settings.authMode === "token" && settings.gatewayToken - ? `Web UI (with token): ${authedUrl}` + ? t("wizard.finalize.webUiWithTokenUrl", { url: authedUrl }) : undefined, - `Gateway WS: ${links.wsUrl}`, + t("wizard.finalize.gatewayWsUrl", { url: links.wsUrl }), gatewayStatusLine, - "Docs: https://docs.openclaw.ai/web/control-ui", + t("wizard.finalize.controlUiDocs"), ] .filter(Boolean) .join("\n"), @@ -416,37 +439,45 @@ export async function finalizeSetupWizard( if (hasBootstrap) { await prompter.note( [ - "Your workspace is ready.", - 'The first Terminal chat run will send: "Wake up, my friend!"', - "Edit BOOTSTRAP.md later to change how the agent introduces itself.", + t("wizard.finalize.workspaceReady"), + t("wizard.finalize.firstTerminalChat"), + t("wizard.finalize.editBootstrap"), ].join("\n"), - "Hatch your agent", + t("wizard.finalize.hatchYourAgent"), ); } if (gatewayProbe.ok) { await prompter.note( [ - "Gateway token: shared auth for the Gateway + Control UI.", - "Stored in: $OPENCLAW_CONFIG_PATH (default: ~/.openclaw/openclaw.json) under gateway.auth.token, or in OPENCLAW_GATEWAY_TOKEN.", - `View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`, - `Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`, - "Web UI keeps dashboard URL tokens in memory for the current tab and strips them from the URL after load.", - `Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`, - "If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).", + t("wizard.finalize.gatewayTokenShared"), + t("wizard.finalize.gatewayTokenStored"), + t("wizard.finalize.gatewayTokenView", { + command: formatCliCommand("openclaw config get gateway.auth.token"), + }), + t("wizard.finalize.gatewayTokenGenerate", { + command: formatCliCommand("openclaw doctor --generate-gateway-token"), + }), + t("wizard.finalize.dashboardTokenMemory"), + t("wizard.finalize.dashboardOpenAnytime", { + command: formatCliCommand("openclaw dashboard --no-open"), + }), + t("wizard.finalize.dashboardTokenPrompt"), ].join("\n"), "Token", ); } const hatchOptions: { value: "tui" | "web" | "later"; label: string }[] = [ - { value: "tui", label: "Hatch in Terminal (recommended)" }, - ...(gatewayProbe.ok ? [{ value: "web" as const, label: "Hatch in Browser" }] : []), - { value: "later", label: "Hatch later" }, + { 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: "How do you want to hatch your agent?", + message: t("wizard.finalize.hatchPrompt"), options: hatchOptions, initialValue: "tui", }); @@ -457,7 +488,7 @@ export async function finalizeSetupWizard( await launchTuiCli({ local: true, deliver: false, - message: hasBootstrap ? "Wake up, my friend!" : undefined, + message: hasBootstrap ? t("wizard.finalize.bootstrapHatchMessage") : undefined, timeoutMs: HATCH_TUI_TIMEOUT_MS, }); } finally { @@ -484,38 +515,34 @@ export async function finalizeSetupWizard( } await prompter.note( [ - `Dashboard link (with token): ${authedUrl}`, + t("wizard.finalize.dashboardLinkWithToken", { url: authedUrl }), controlUiOpened - ? "Opened in your browser. Keep that tab to control OpenClaw." - : "Copy/paste this URL in a browser on this machine to control OpenClaw.", + ? t("wizard.finalize.dashboardOpened") + : t("wizard.finalize.dashboardCopyPaste"), controlUiOpenHint, ] .filter(Boolean) .join("\n"), - "Dashboard ready", + t("wizard.finalize.dashboardReady"), ); } else { await prompter.note( - `When you're ready: ${formatCliCommand("openclaw dashboard --no-open")}`, - "Later", + t("wizard.finalize.dashboardWhenReady", { + command: formatCliCommand("openclaw dashboard --no-open"), + }), + t("wizard.finalize.laterTitle"), ); } } else if (opts.skipUi) { - await prompter.note("Skipping Control UI/TUI prompts.", "Control UI"); + await prompter.note(t("wizard.finalize.skipControlUi"), t("wizard.finalize.controlUiTitle")); } await prompter.note( - [ - "Back up your agent workspace.", - "Docs: https://docs.openclaw.ai/concepts/agent-workspace", - ].join("\n"), - "Workspace backup", + [t("wizard.finalize.backupWorkspace"), t("wizard.finalize.workspaceDocs")].join("\n"), + t("wizard.finalize.workspaceBackupTitle"), ); - await prompter.note( - "Running agents on your computer is risky — harden your setup: https://docs.openclaw.ai/security", - "Security", - ); + await prompter.note(t("wizard.finalize.securityReminder"), t("wizard.security.title")); await setupWizardShellCompletion({ flow, prompter }); @@ -546,15 +573,15 @@ export async function finalizeSetupWizard( await prompter.note( [ - `Dashboard link (with token): ${authedUrl}`, + t("wizard.finalize.dashboardLinkWithToken", { url: authedUrl }), controlUiOpened - ? "Opened in your browser. Keep that tab to control OpenClaw." - : "Copy/paste this URL in a browser on this machine to control OpenClaw.", + ? t("wizard.finalize.dashboardOpened") + : t("wizard.finalize.dashboardCopyPaste"), controlUiOpenHint, ] .filter(Boolean) .join("\n"), - "Dashboard ready", + t("wizard.finalize.dashboardReady"), ); } @@ -571,55 +598,59 @@ export async function finalizeSetupWizard( const envAvailable = entry ? hasKeyInEnv(entry) : false; const hasKey = keyConfigured || envAvailable; const keySource = storedKey - ? "API key: stored in config." + ? t("wizard.finalize.webSearchKeyStored") : keyConfigured - ? "API key: configured via secret reference." + ? t("wizard.finalize.webSearchKeyRef") : envAvailable - ? `API key: provided via ${entry?.envVars.join(" / ")} env var.` + ? t("wizard.finalize.webSearchKeyEnv", { env: entry?.envVars.join(" / ") ?? "" }) : undefined; if (!entry) { await prompter.note( [ - `Web search provider ${label} is selected but unavailable under the current plugin policy.`, - "web_search will not work until the provider is re-enabled or a different provider is selected.", + t("wizard.finalize.webSearchProviderUnavailable", { provider: label }), + t("wizard.finalize.webSearchUnavailableAction"), ` ${formatCliCommand("openclaw configure --section web")}`, "", - "Docs: https://docs.openclaw.ai/tools/web", + t("wizard.finalize.webDocs"), ].join("\n"), - "Web search", + t("wizard.finalize.webSearchTitle"), ); } else if (webSearchEnabled !== false && hasKey) { await prompter.note( [ - "Web search is enabled, so your agent can look things up online when needed.", + t("wizard.finalize.webSearchEnabled"), "", - `Provider: ${label}`, + t("wizard.finalize.webSearchProvider", { provider: label }), ...(keySource ? [keySource] : []), - "Docs: https://docs.openclaw.ai/tools/web", + t("wizard.finalize.webDocs"), ].join("\n"), - "Web search", + t("wizard.finalize.webSearchTitle"), ); } else if (!hasKey) { await prompter.note( [ - `Provider ${label} is selected but no API key was found.`, - "web_search will not work until a key is added.", + t("wizard.finalize.webSearchNoKey", { provider: label }), + t("wizard.finalize.webSearchNeedsKey"), ` ${formatCliCommand("openclaw configure --section web")}`, "", - `Get your key at: ${entry?.signupUrl ?? "https://docs.openclaw.ai/tools/web"}`, - "Docs: https://docs.openclaw.ai/tools/web", + t("wizard.finalize.webSearchGetKey", { + url: entry?.signupUrl ?? "https://docs.openclaw.ai/tools/web", + }), + t("wizard.finalize.webDocs"), ].join("\n"), - "Web search", + t("wizard.finalize.webSearchTitle"), ); } else { await prompter.note( [ - `Web search (${label}) is configured but disabled.`, - `Re-enable: ${formatCliCommand("openclaw configure --section web")}`, + t("wizard.finalize.webSearchDisabled", { provider: label }), + t("wizard.finalize.webSearchReenable", { + command: formatCliCommand("openclaw configure --section web"), + }), "", - "Docs: https://docs.openclaw.ai/tools/web", + t("wizard.finalize.webDocs"), ].join("\n"), - "Web search", + t("wizard.finalize.webSearchTitle"), ); } } else { @@ -632,29 +663,29 @@ export async function finalizeSetupWizard( if (legacyDetected) { await prompter.note( [ - `Web search is available via ${legacyDetected.label} (auto-detected).`, - "Docs: https://docs.openclaw.ai/tools/web", + t("wizard.finalize.webSearchAutoDetected", { provider: legacyDetected.label }), + t("wizard.finalize.webDocs"), ].join("\n"), - "Web search", + t("wizard.finalize.webSearchTitle"), ); } else if (codexNativeSummary) { await prompter.note( [ - "Managed web search provider was skipped.", + t("wizard.finalize.managedWebSearchSkipped"), codexNativeSummary, - "Docs: https://docs.openclaw.ai/tools/web", + t("wizard.finalize.webDocs"), ].join("\n"), - "Web search", + t("wizard.finalize.webSearchTitle"), ); } else { await prompter.note( [ - "Web search was skipped. You can enable it later:", + t("wizard.finalize.webSearchSkipped"), ` ${formatCliCommand("openclaw configure --section web")}`, "", - "Docs: https://docs.openclaw.ai/tools/web", + t("wizard.finalize.webDocs"), ].join("\n"), - "Web search", + t("wizard.finalize.webSearchTitle"), ); } } @@ -663,24 +694,21 @@ export async function finalizeSetupWizard( await prompter.note( [ codexNativeSummary, - "Used only for Codex-capable models.", - "Docs: https://docs.openclaw.ai/tools/web", + t("wizard.finalize.codexNativeSearchOnly"), + t("wizard.finalize.webDocs"), ].join("\n"), - "Codex native search", + t("wizard.finalize.codexNativeSearchTitle"), ); } - await prompter.note( - 'What now: https://openclaw.ai/showcase ("What People Are Building").', - "What now", - ); + await prompter.note(t("wizard.finalize.whatNow"), t("wizard.finalize.whatNowTitle")); await prompter.outro( controlUiOpened - ? "Onboarding complete. Dashboard opened; keep that tab to control OpenClaw." + ? t("wizard.finalize.outroDashboardOpened") : seededInBackground - ? "Onboarding complete. Web UI seeded in the background; open it anytime with the dashboard link above." - : "Onboarding complete. Use the dashboard link above to control OpenClaw.", + ? t("wizard.finalize.outroSeeded") + : t("wizard.finalize.outroDashboardLink"), ); return { launchedTui }; diff --git a/src/wizard/setup.gateway-config.ts b/src/wizard/setup.gateway-config.ts index 8b599d631c8..ba07c906d66 100644 --- a/src/wizard/setup.gateway-config.ts +++ b/src/wizard/setup.gateway-config.ts @@ -14,9 +14,7 @@ import { } from "../config/types.secrets.js"; import { maybeAddTailnetOriginToControlUiAllowedOrigins, - TAILSCALE_DOCS_LINES, TAILSCALE_EXPOSURE_OPTIONS, - TAILSCALE_MISSING_BIN_NOTE_LINES, } from "../gateway/gateway-config-prompts.shared.js"; import { DEFAULT_DANGEROUS_NODE_COMMANDS } from "../gateway/node-command-policy.js"; import { findTailscaleBinary } from "../infra/tailscale.js"; @@ -25,6 +23,7 @@ import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js"; import type { RuntimeEnv } from "../runtime.js"; import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; import { maskApiKey } from "../utils/mask-api-key.js"; +import { t } from "./i18n/index.js"; import type { WizardPrompter } from "./prompts.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; import type { @@ -49,6 +48,14 @@ type ConfigureGatewayResult = { settings: GatewayWizardSettings; }; +function getLocalizedTailscaleExposureOptions() { + return TAILSCALE_EXPOSURE_OPTIONS.map((option) => ({ + hint: t(`wizard.gatewayTailscale.${option.value}Hint`), + label: t(`wizard.gatewayTailscale.${option.value}`), + value: option.value, + })); +} + function normalizeWizardTextInput(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } @@ -73,7 +80,7 @@ export async function configureGatewayForSetup( : Number.parseInt( normalizeWizardTextInput( await prompter.text({ - message: "Gateway port", + message: t("wizard.gateway.port"), initialValue: String(localPort), validate: validateGatewayPortInput, }), @@ -85,13 +92,33 @@ export async function configureGatewayForSetup( flow === "quickstart" ? quickstartGateway.bind : await prompter.select({ - message: "Gateway bind address", + message: t("wizard.gateway.bindAddress"), options: [ - { value: "loopback", label: "Loopback (127.0.0.1)", hint: "This machine only" }, - { value: "lan", label: "LAN (0.0.0.0)", hint: "Reachable on your local network" }, - { value: "tailnet", label: "Tailnet (Tailscale IP)", hint: "Reachable over Tailscale" }, - { value: "auto", label: "Auto (Loopback -> LAN)", hint: "Try loopback first" }, - { value: "custom", label: "Custom IP", hint: "Bind to one local address" }, + { + value: "loopback", + label: t("wizard.gateway.bindLoopback"), + hint: t("wizard.gateway.bindLoopbackHint"), + }, + { + value: "lan", + label: t("wizard.gateway.bindLan"), + hint: t("wizard.gateway.bindLanHint"), + }, + { + value: "tailnet", + label: t("wizard.gateway.bindTailnet"), + hint: t("wizard.gateway.bindTailnetHint"), + }, + { + value: "auto", + label: t("wizard.gateway.bindAuto"), + hint: t("wizard.gateway.bindAutoHint"), + }, + { + value: "custom", + label: t("wizard.gateway.bindCustom"), + hint: t("wizard.gateway.bindCustomHint"), + }, ], }); @@ -100,7 +127,7 @@ export async function configureGatewayForSetup( const needsPrompt = flow !== "quickstart" || !customBindHost; if (needsPrompt) { const input = await prompter.text({ - message: "Custom IP address", + message: t("wizard.gateway.bindCustomIp"), placeholder: "192.168.1.100", initialValue: customBindHost ?? "", validate: validateIPv4AddressInput, @@ -113,14 +140,14 @@ export async function configureGatewayForSetup( flow === "quickstart" ? quickstartGateway.authMode : ((await prompter.select({ - message: "Gateway access protection", + message: t("wizard.gateway.accessProtection"), options: [ { value: "token", - label: "Token (recommended)", - hint: "Recommended default (local + remote)", + label: t("common.tokenRecommended"), + hint: t("wizard.gateway.plaintextTokenHint"), }, - { value: "password", label: "Password" }, + { value: "password", label: t("common.password") }, ], initialValue: "token", })) as GatewayAuthChoice); @@ -129,8 +156,8 @@ export async function configureGatewayForSetup( flow === "quickstart" ? quickstartGateway.tailscaleMode : await prompter.select({ - message: "Tailscale exposure", - options: [...TAILSCALE_EXPOSURE_OPTIONS], + message: t("wizard.gateway.tailscaleExposure"), + options: getLocalizedTailscaleExposureOptions(), }); // Detect Tailscale binary before proceeding with serve/funnel setup. @@ -139,15 +166,18 @@ export async function configureGatewayForSetup( if (tailscaleMode !== "off") { tailscaleBin = await findTailscaleBinary(); if (!tailscaleBin) { - await prompter.note(TAILSCALE_MISSING_BIN_NOTE_LINES.join("\n"), "Tailscale Warning"); + await prompter.note( + t("wizard.gatewayTailscale.missingBinNote"), + t("wizard.gatewayTailscale.warningTitle"), + ); } } let tailscaleResetOnExit = flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false; if (tailscaleMode !== "off" && flow !== "quickstart") { - await prompter.note(TAILSCALE_DOCS_LINES.join("\n"), "Tailscale"); + await prompter.note(t("wizard.gatewayTailscale.docsNote"), "Tailscale"); tailscaleResetOnExit = await prompter.confirm({ - message: "Reset Tailscale serve/funnel on exit?", + message: t("wizard.gateway.tailscaleReset"), initialValue: false, }); } @@ -157,18 +187,15 @@ export async function configureGatewayForSetup( // - Funnel requires password auth. if (tailscaleMode !== "off" && bind !== "loopback") { await prompter.note( - "Tailscale exposure requires bind=loopback. I will switch the bind address to loopback.", - "Gateway bind", + t("wizard.gatewayNotes.tailscaleBindLoopback"), + t("wizard.gatewayNotes.bindTitle"), ); bind = "loopback"; customBindHost = undefined; } if (tailscaleMode === "funnel" && authMode !== "password") { - await prompter.note( - "Tailscale Funnel requires password auth. I will switch Gateway auth to password.", - "Gateway auth", - ); + await prompter.note(t("wizard.gatewayNotes.tailscaleFunnelPassword"), t("wizard.gateway.auth")); authMode = "password"; } @@ -189,11 +216,11 @@ export async function configureGatewayForSetup( prompter, explicitMode: opts.secretInputMode, copy: { - modeMessage: "How do you want to provide the gateway token?", - plaintextLabel: "Generate/store plaintext token", - plaintextHint: "Default", - refLabel: "Use SecretRef", - refHint: "Store a reference instead of plaintext", + modeMessage: t("wizard.gateway.authTokenMode"), + plaintextLabel: t("wizard.gateway.plaintextTokenLabel"), + plaintextHint: t("wizard.gateway.plaintextTokenHint"), + refLabel: t("wizard.gateway.refLabel"), + refHint: t("wizard.gateway.refHint"), }, }); if (tokenMode === "ref") { @@ -212,7 +239,7 @@ export async function configureGatewayForSetup( prompter, preferredEnvVar: "OPENCLAW_GATEWAY_TOKEN", copy: { - sourceMessage: "Where is this gateway token stored?", + sourceMessage: t("wizard.gateway.authTokenStoredMessage"), envVarPlaceholder: "OPENCLAW_GATEWAY_TOKEN", }, }); @@ -230,20 +257,20 @@ export async function configureGatewayForSetup( let tokenInput: string | undefined; if (existingToken) { const keep = await prompter.confirm({ - message: `Use existing gateway token (${maskApiKey(existingToken)})?`, + message: t("wizard.gateway.existingTokenConfirm", { token: maskApiKey(existingToken) }), initialValue: true, }); tokenInput = keep ? existingToken : await prompter.text({ - message: "Gateway token (blank to generate)", - placeholder: "Needed for multi-machine or non-loopback access", + message: t("wizard.gateway.tokenPromptGenerate"), + placeholder: t("wizard.gateway.tokenPlaceholder"), sensitive: true, }); } else { tokenInput = await prompter.text({ - message: "Gateway token (blank to generate)", - placeholder: "Needed for multi-machine or non-loopback access", + message: t("wizard.gateway.tokenPromptGenerate"), + placeholder: t("wizard.gateway.tokenPlaceholder"), sensitive: true, }); } @@ -260,9 +287,9 @@ export async function configureGatewayForSetup( prompter, explicitMode: opts.secretInputMode, copy: { - modeMessage: "How do you want to provide the gateway password?", - plaintextLabel: "Enter password now", - plaintextHint: "Stores the password directly in OpenClaw config", + modeMessage: t("wizard.gateway.authPasswordMode"), + plaintextLabel: t("wizard.gateway.plaintextPasswordLabel"), + plaintextHint: t("wizard.gateway.plaintextPasswordHint"), }, }); if (selectedMode === "ref") { @@ -272,7 +299,7 @@ export async function configureGatewayForSetup( prompter, preferredEnvVar: "OPENCLAW_GATEWAY_PASSWORD", copy: { - sourceMessage: "Where is this gateway password stored?", + sourceMessage: t("wizard.gateway.authPasswordStoredMessage"), envVarPlaceholder: "OPENCLAW_GATEWAY_PASSWORD", }, }); @@ -280,7 +307,7 @@ export async function configureGatewayForSetup( } else { password = normalizeWizardTextInput( await prompter.text({ - message: "Gateway password", + message: t("wizard.gateway.passwordPrompt"), validate: validateGatewayPasswordInput, sensitive: true, }), diff --git a/src/wizard/setup.migration-import.ts b/src/wizard/setup.migration-import.ts index 62abc0df037..376a91a1799 100644 --- a/src/wizard/setup.migration-import.ts +++ b/src/wizard/setup.migration-import.ts @@ -6,6 +6,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import type { MigrationProviderPlugin } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; +import { t } from "./i18n/index.js"; import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; export type SetupMigrationDetection = { @@ -169,7 +170,7 @@ async function selectSetupMigrationProvider(params: { const providerId = params.opts.importFrom?.trim() || (await params.prompter.select({ - message: "Migration source", + message: t("wizard.migration.source"), options: [ ...params.detections.map((detection) => ({ value: detection.providerId, @@ -186,7 +187,7 @@ async function selectSetupMigrationProvider(params: { .map((provider) => ({ value: provider.id, label: provider.label, - hint: provider.description ?? "Enter a source path next", + hint: provider.description ?? t("wizard.migration.sourcePathHint"), })), ], initialValue: params.detections[0]?.providerId ?? providers[0]?.id, @@ -238,7 +239,7 @@ export async function runSetupMigrationImport(params: { throw new Error("--import-source is required for non-interactive migration import."); })() : await params.prompter.text({ - message: "Source agent home", + message: t("wizard.migration.sourceAgentHome"), initialValue: providerId === "hermes" ? "~/.hermes" : undefined, })); const workspaceInput = @@ -246,7 +247,7 @@ export async function runSetupMigrationImport(params: { (params.opts.nonInteractive ? (params.baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE) : await params.prompter.text({ - message: "Target workspace directory", + message: t("wizard.migration.targetWorkspace"), initialValue: params.baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE, })); @@ -273,18 +274,21 @@ export async function runSetupMigrationImport(params: { logger: createMigrationLogger(params.runtime), }; const plan = await provider.plan(ctx); - await params.prompter.note(formatMigrationPreview(plan).join("\n"), "Migration preview"); + await params.prompter.note( + formatMigrationPreview(plan).join("\n"), + t("wizard.migration.previewTitle"), + ); assertConflictFreePlan(plan, providerId); const confirmed = params.opts.nonInteractive === true ? true : await params.prompter.confirm({ - message: "Apply this migration now?", + message: t("wizard.migration.apply"), initialValue: false, }); if (!confirmed) { - throw new WizardCancelledError("migration cancelled"); + throw new WizardCancelledError(t("wizard.migration.cancelled")); } const reportDir = buildMigrationReportDir(providerId, stateDir); @@ -307,6 +311,9 @@ export async function runSetupMigrationImport(params: { reportDir: result.reportDir ?? reportDir, }; assertApplySucceeded(withReport); - await params.prompter.note(formatMigrationResult(withReport).join("\n"), "Migration applied"); - await params.prompter.outro("Migration complete. Run `openclaw doctor` next."); + await params.prompter.note( + formatMigrationResult(withReport).join("\n"), + t("wizard.migration.appliedTitle"), + ); + await params.prompter.outro(t("wizard.migration.complete")); } diff --git a/src/wizard/setup.official-plugins.ts b/src/wizard/setup.official-plugins.ts index 08f8163ab96..08a07e6bbd9 100644 --- a/src/wizard/setup.official-plugins.ts +++ b/src/wizard/setup.official-plugins.ts @@ -9,6 +9,7 @@ import { resolveOfficialExternalPluginLabel, } from "../plugins/official-external-plugin-catalog.js"; import type { RuntimeEnv } from "../runtime.js"; +import { t } from "./i18n/index.js"; import type { WizardPrompter } from "./prompts.js"; const SKIP_VALUE = "__skip__"; @@ -97,12 +98,12 @@ export async function setupOfficialPluginInstalls(params: { } const selected = await params.prompter.multiselect({ - message: "Install optional plugins", + message: t("wizard.plugins.officialInstall"), options: [ { value: SKIP_VALUE, - label: "Skip for now", - hint: "Continue without installing optional plugins", + label: t("common.skipForNow"), + hint: t("wizard.plugins.officialSkipHint"), }, ...installEntries.map((entry) => ({ value: entry.pluginId, diff --git a/src/wizard/setup.plugin-config.ts b/src/wizard/setup.plugin-config.ts index 8bafb79cf23..c52d0ea76bf 100644 --- a/src/wizard/setup.plugin-config.ts +++ b/src/wizard/setup.plugin-config.ts @@ -3,6 +3,7 @@ import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import type { PluginConfigUiHint } from "../plugins/types.js"; import { getPath, setPathCreateStrict } from "../secrets/path-utils.js"; import type { JsonSchemaObject } from "../shared/json-schema.types.js"; +import { t } from "./i18n/index.js"; import type { WizardPrompter } from "./prompts.js"; /** @@ -192,8 +193,12 @@ async function promptPluginFields(params: { // direct users to openclaw config set or the Web UI instead. if (hint.sensitive) { await prompter.note( - `"${label}" is sensitive. Set it via:\n openclaw config set plugins.entries.${plugin.id}.config.${key} \nor use the Web UI Settings page.`, - "Sensitive field", + t("wizard.plugins.sensitiveField", { + label, + plugin: plugin.id, + field: key, + }), + t("wizard.plugins.sensitiveTitle"), ); continue; } @@ -207,7 +212,7 @@ async function promptPluginFields(params: { if (hasValue) { options.unshift({ value: "__keep__", - label: `Keep current (${formatCurrentValue(currentValue)})`, + label: t("wizard.plugins.currentValue", { value: formatCurrentValue(currentValue) }), }); } const selected = await prompter.select({ @@ -239,9 +244,9 @@ async function promptPluginFields(params: { if (schemaProp?.type === "array") { const currentStr = Array.isArray(currentValue) ? (currentValue as unknown[]).join(", ") : ""; const input = await prompter.text({ - message: `${label} (comma-separated, empty to clear)${helpSuffix}`, + message: `${label}${t("wizard.plugins.arrayPromptSuffix")}${helpSuffix}`, initialValue: currentStr, - placeholder: hint.placeholder ?? "value1, value2", + placeholder: hint.placeholder ?? t("wizard.plugins.arrayPlaceholder"), }); const trimmed = input.trim(); if (trimmed !== currentStr) { @@ -331,17 +336,20 @@ export async function setupPluginConfig(params: { } const selected = await params.prompter.multiselect({ - message: "Configure plugins (select to set up now, or skip)", + message: t("wizard.plugins.configureSelectOnboard"), options: [ { value: "__skip__", - label: "Skip for now", - hint: "Continue without configuring plugins", + label: t("common.skipForNow"), + hint: t("wizard.plugins.skipConfigHint"), }, ...unconfigured.map((p) => ({ value: p.id, label: p.name, - hint: `${Object.keys(p.uiHints).length} field${Object.keys(p.uiHints).length === 1 ? "" : "s"}`, + hint: t("wizard.plugins.fieldsCount", { + count: Object.keys(p.uiHints).length, + plural: Object.keys(p.uiHints).length === 1 ? "" : "s", + }), })), ], }); @@ -352,7 +360,10 @@ export async function setupPluginConfig(params: { if (!plugin) { continue; } - await params.prompter.note(`Configure ${plugin.name}`, "Plugin setup"); + await params.prompter.note( + t("wizard.plugins.configurePlugin", { plugin: plugin.name }), + t("wizard.plugins.configureFieldsTitle"), + ); config = await promptPluginFields({ plugin, config, @@ -382,12 +393,15 @@ export async function configurePluginConfig(params: { }); if (configurable.length === 0) { - await params.prompter.note("No plugins with configurable fields found.", "Plugins"); + await params.prompter.note( + t("wizard.plugins.configureEmpty"), + t("wizard.plugins.configureEmptyTitle"), + ); return params.config; } const selected = await params.prompter.select({ - message: "Select plugin to configure", + message: t("wizard.plugins.configureSelect"), options: [ ...configurable.map((p) => { const existing = getExistingPluginConfig(params.config, p.id); @@ -399,10 +413,13 @@ export async function configurePluginConfig(params: { return { value: p.id, label: p.name, - hint: `${configuredCount}/${totalCount} configured`, + hint: t("wizard.plugins.configuredCount", { + configured: configuredCount, + total: totalCount, + }), }; }), - { value: "__skip__", label: "Back", hint: "Return to section menu" }, + { value: "__skip__", label: t("common.back"), hint: t("wizard.plugins.configureBackHint") }, ], searchable: true, }); diff --git a/src/wizard/setup.security-note.ts b/src/wizard/setup.security-note.ts index 117875b37d5..62bdd59c0a0 100644 --- a/src/wizard/setup.security-note.ts +++ b/src/wizard/setup.security-note.ts @@ -1,37 +1,43 @@ import chalk from "chalk"; import { formatCliCommand } from "../cli/command-format.js"; - -export const SECURITY_NOTE_TITLE = "Security disclaimer"; - -export const SECURITY_CONFIRM_MESSAGE = - "I understand this is personal-by-default and shared/multi-user use requires lock-down. Continue?"; +import { t } from "./i18n/index.js"; const heading = (text: string) => chalk.bold(text); -export const SECURITY_NOTE_MESSAGE = [ - "OpenClaw is a hobby project and still in beta. Expect sharp edges.", - "By default, OpenClaw is a personal agent: one trusted operator boundary.", - "This bot can read files and run actions if tools are enabled.", - "A bad prompt can trick it into doing unsafe things.", - "", - "OpenClaw is not a hostile multi-tenant boundary by default.", - "If multiple users can message one tool-enabled agent, they share that delegated tool authority.", - "", - "If you’re not comfortable with security hardening and access control, don’t run OpenClaw.", - "Ask someone experienced to help before enabling tools or exposing it to the internet.", - "", - heading("Recommended baseline"), - "- Pairing/allowlists + mention gating.", - "- Multi-user/shared inbox: split trust boundaries (separate gateway/credentials, ideally separate OS users/hosts).", - "- Sandbox + least-privilege tools.", - "- Shared inboxes: isolate DM sessions (session.dmScope: per-channel-peer) and keep tool access minimal.", - "- Keep secrets out of the agent’s reachable filesystem.", - "- Use the strongest available model for any bot with tools or untrusted inboxes.", - "", - heading("Run regularly"), - formatCliCommand("openclaw security audit --deep"), - formatCliCommand("openclaw security audit --fix"), - "", - heading("Learn more"), - "- https://docs.openclaw.ai/gateway/security", -].join("\n"); +export function getSecurityNoteTitle(): string { + return t("wizard.security.title"); +} + +export function getSecurityConfirmMessage(): string { + return t("wizard.security.confirm"); +} + +export function getSecurityNoteMessage(): string { + return [ + t("wizard.security.beta"), + t("wizard.security.personalAgent"), + t("wizard.security.toolAccess"), + t("wizard.security.promptRisk"), + "", + t("wizard.security.notMultitenant"), + t("wizard.security.sharedAuthority"), + "", + t("wizard.security.hardeningRequired"), + t("wizard.security.askForHelp"), + "", + heading(t("wizard.security.recommendedBaseline")), + `- ${t("wizard.security.baselinePairing")}`, + `- ${t("wizard.security.baselineSharedInbox")}`, + `- ${t("wizard.security.baselineSandbox")}`, + `- ${t("wizard.security.baselineDmSessions")}`, + `- ${t("wizard.security.baselineSecrets")}`, + `- ${t("wizard.security.baselineStrongModel")}`, + "", + heading(t("wizard.security.runRegularly")), + formatCliCommand("openclaw security audit --deep"), + formatCliCommand("openclaw security audit --fix"), + "", + heading(t("wizard.security.learnMore")), + "- https://docs.openclaw.ai/gateway/security", + ].join("\n"); +} diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 7a68a68047e..a3203388c2c 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -1101,6 +1101,55 @@ describe("runSetupWizard", () => { expect(matchingQuickStartNotes.length).toBeGreaterThan(0); }); + it("localizes the quickstart summary", async () => { + const previousPort = process.env.OPENCLAW_GATEWAY_PORT; + const previousLocale = process.env.OPENCLAW_LOCALE; + process.env.OPENCLAW_GATEWAY_PORT = "18791"; + process.env.OPENCLAW_LOCALE = "zh-CN"; + const note: WizardPrompter["note"] = vi.fn(async () => {}); + const prompter = buildWizardPrompter({ note }); + const runtime = createRuntime(); + + try { + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "skip", + installDaemon: false, + skipProviders: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + runtime, + prompter, + ); + } finally { + if (previousPort === undefined) { + delete process.env.OPENCLAW_GATEWAY_PORT; + } else { + process.env.OPENCLAW_GATEWAY_PORT = previousPort; + } + if (previousLocale === undefined) { + delete process.env.OPENCLAW_LOCALE; + } else { + process.env.OPENCLAW_LOCALE = previousLocale; + } + } + + const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls; + const matchingQuickStartNotes = calls.filter( + (call) => + call?.[1] === "QuickStart" && + typeof call?.[0] === "string" && + call[0].includes("Gateway 端口:18791") && + call[0].includes("Tailscale 暴露方式:关闭"), + ); + expect(matchingQuickStartNotes.length).toBeGreaterThan(0); + }); + it("uses manifest setup metadata for post-auth model policy without loading provider runtime", async () => { promptDefaultModel.mockClear(); resolvePluginProvidersRuntime.mockClear(); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 5cdfe1ae6af..44ca308407f 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -19,13 +19,14 @@ import { import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; +import { t } from "./i18n/index.js"; import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; import { detectSetupMigrationSources, runSetupMigrationImport } from "./setup.migration-import.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; import { - SECURITY_CONFIRM_MESSAGE, - SECURITY_NOTE_MESSAGE, - SECURITY_NOTE_TITLE, + getSecurityConfirmMessage, + getSecurityNoteMessage, + getSecurityNoteTitle, } from "./setup.security-note.js"; import type { QuickstartGatewayDefaults, WizardFlow } from "./setup.types.js"; @@ -165,14 +166,14 @@ async function requireRiskAcknowledgement(params: { return; } - await params.prompter.note(SECURITY_NOTE_MESSAGE, SECURITY_NOTE_TITLE); + await params.prompter.note(getSecurityNoteMessage(), getSecurityNoteTitle()); const ok = await params.prompter.confirm({ - message: SECURITY_CONFIRM_MESSAGE, + message: getSecurityConfirmMessage(), initialValue: false, }); if (!ok) { - throw new WizardCancelledError("risk not accepted"); + throw new WizardCancelledError(t("wizard.setup.riskNotAccepted")); } } @@ -184,7 +185,7 @@ export async function runSetupWizard( runtime ??= defaultRuntime; const onboardHelpers = await import("../commands/onboard-helpers.js"); onboardHelpers.printWizardHeader(runtime); - await prompter.intro("OpenClaw setup"); + await prompter.intro(t("wizard.setup.intro")); await requireRiskAcknowledgement({ opts, prompter }); const snapshot = await readSetupConfigFileSnapshot(); @@ -195,7 +196,10 @@ export async function runSetupWizard( : {}; if (snapshot.exists && !snapshot.valid) { - await prompter.note(onboardHelpers.summarizeExistingConfig(baseConfig), "Invalid config"); + await prompter.note( + onboardHelpers.summarizeExistingConfig(baseConfig), + t("wizard.setup.invalidConfigTitle"), + ); if (snapshot.issues.length > 0) { await prompter.note( [ @@ -230,12 +234,14 @@ export async function runSetupWizard( `Review: ${formatCliCommand("openclaw doctor")}`, `Inspect: ${formatCliCommand("openclaw plugins inspect --all")}`, ].join("\n"), - "Plugin compatibility", + t("wizard.setup.pluginCompatibilityTitle"), ); } - const quickstartHint = `Recommended local setup. Change details later with ${formatCliCommand("openclaw configure")}.`; - const manualHint = "Choose Gateway port, network exposure, Tailscale, and auth."; + const quickstartHint = t("wizard.setup.flowQuickstartHint", { + command: formatCliCommand("openclaw configure"), + }); + const manualHint = t("wizard.setup.flowAdvancedHint"); const migrationDetections = await detectSetupMigrationSources({ config: baseConfig, runtime }); const firstMigrationDetection = migrationDetections[0]; const importOption = firstMigrationDetection @@ -268,35 +274,32 @@ export async function runSetupWizard( let flow: SetupFlowChoice = explicitFlow ?? (await prompter.select({ - message: "Setup mode", + message: t("wizard.setup.setupMode"), options: [ - { value: "quickstart", label: "QuickStart (recommended)", hint: quickstartHint }, - { value: "advanced", label: "Manual setup", hint: manualHint }, + { value: "quickstart", label: t("wizard.setup.flowQuickstart"), hint: quickstartHint }, + { value: "advanced", label: t("wizard.setup.flowAdvanced"), hint: manualHint }, ...(importOption ? [importOption] : []), ], initialValue: "quickstart", })); if (opts.mode === "remote" && flow === "quickstart") { - await prompter.note( - "QuickStart only supports local gateways. Switching to Manual mode.", - "QuickStart", - ); + await prompter.note(t("wizard.setup.quickstartOnlyLocal"), t("wizard.setup.quickstartTitle")); flow = "advanced"; } if (snapshot.exists) { await prompter.note( onboardHelpers.summarizeExistingConfig(baseConfig), - "Existing config detected", + t("wizard.setup.existingConfigTitle"), ); const action = await prompter.select({ - message: "Config handling", + message: t("wizard.setup.configHandling"), options: [ - { value: "keep", label: "Keep current values" }, - { value: "modify", label: "Review and update" }, - { value: "reset", label: "Reset before setup" }, + { value: "keep", label: t("wizard.setup.keepCurrent") }, + { value: "modify", label: t("wizard.setup.modifyCurrent") }, + { value: "reset", label: t("wizard.setup.resetBefore") }, ], }); @@ -304,16 +307,16 @@ export async function runSetupWizard( const workspaceDefault = baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE; const resetScope = (await prompter.select({ - message: "Reset scope", + message: t("wizard.setup.resetScope"), options: [ - { value: "config", label: "Config only" }, + { value: "config", label: t("wizard.setup.resetConfig") }, { value: "config+creds+sessions", - label: "Config + creds + sessions", + label: t("wizard.setup.resetConfigCredsSessions"), }, { value: "full", - label: "Full reset (config + creds + sessions + workspace)", + label: t("wizard.setup.resetFull"), }, ], })) as ResetScope; @@ -389,52 +392,58 @@ export async function runSetupWizard( if (flow === "quickstart") { const formatBind = (value: "loopback" | "lan" | "auto" | "custom" | "tailnet") => { if (value === "loopback") { - return "Loopback (127.0.0.1)"; + return t("wizard.gateway.bindLoopback"); } if (value === "lan") { - return "LAN"; + return t("wizard.gateway.bindLan"); } if (value === "custom") { - return "Custom IP"; + return t("wizard.gateway.bindCustom"); } if (value === "tailnet") { - return "Tailnet (Tailscale IP)"; + return t("wizard.gateway.bindTailnet"); } - return "Auto"; + return t("wizard.gateway.bindAuto"); }; const formatAuth = (value: GatewayAuthChoice) => { if (value === "token") { - return "Token (default)"; + return t("wizard.setup.quickstartAuthTokenDefault"); } - return "Password"; + return t("common.password"); }; const formatTailscale = (value: "off" | "serve" | "funnel") => { - if (value === "off") { - return "Off"; - } - if (value === "serve") { - return "Serve"; - } - return "Funnel"; + return t(`wizard.gatewayTailscale.${value}`); }; const quickstartLines = quickstartGateway.hasExisting ? [ - "Keeping your current gateway settings:", - `Gateway port: ${quickstartGateway.port}`, - `Gateway bind: ${formatBind(quickstartGateway.bind)}`, + t("wizard.setup.quickstartKeepSettings"), + t("wizard.setup.quickstartGatewayPort", { port: quickstartGateway.port }), + t("wizard.setup.quickstartGatewayBind", { bind: formatBind(quickstartGateway.bind) }), ...(quickstartGateway.bind === "custom" && quickstartGateway.customBindHost - ? [`Gateway custom IP: ${quickstartGateway.customBindHost}`] + ? [ + t("wizard.setup.quickstartGatewayCustomIp", { + host: quickstartGateway.customBindHost, + }), + ] : []), - `Gateway auth: ${formatAuth(quickstartGateway.authMode)}`, - `Tailscale exposure: ${formatTailscale(quickstartGateway.tailscaleMode)}`, - "Direct to chat channels.", + t("wizard.setup.quickstartGatewayAuth", { + auth: formatAuth(quickstartGateway.authMode), + }), + t("wizard.setup.quickstartTailscaleExposure", { + exposure: formatTailscale(quickstartGateway.tailscaleMode), + }), + t("wizard.setup.quickstartDirectChannels"), ] : [ - `Gateway port: ${quickstartGateway.port}`, - "Gateway bind: Loopback (127.0.0.1)", - "Gateway auth: Token (default)", - "Tailscale exposure: Off", - "Direct to chat channels.", + t("wizard.setup.quickstartGatewayPort", { port: quickstartGateway.port }), + t("wizard.setup.quickstartGatewayBind", { bind: t("wizard.gateway.bindLoopback") }), + t("wizard.setup.quickstartGatewayAuth", { + auth: t("wizard.setup.quickstartAuthTokenDefault"), + }), + t("wizard.setup.quickstartTailscaleExposure", { + exposure: t("wizard.gatewayTailscale.off"), + }), + t("wizard.setup.quickstartDirectChannels"), ]; await prompter.note(quickstartLines.join("\n"), "QuickStart"); } @@ -455,10 +464,10 @@ export async function runSetupWizard( } catch (error) { await prompter.note( [ - "Could not resolve gateway.auth.token SecretRef for setup probe.", + t("wizard.setup.secretRefProbeFailed", { field: "gateway.auth.token" }), formatErrorMessage(error), ].join("\n"), - "Gateway auth", + t("wizard.gateway.auth"), ); } let localGatewayPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; @@ -475,10 +484,10 @@ export async function runSetupWizard( } catch (error) { await prompter.note( [ - "Could not resolve gateway.auth.password SecretRef for setup probe.", + t("wizard.setup.secretRefProbeFailed", { field: "gateway.auth.password" }), formatErrorMessage(error), ].join("\n"), - "Gateway auth", + t("wizard.gateway.auth"), ); } @@ -520,23 +529,23 @@ export async function runSetupWizard( (flow === "quickstart" ? "local" : ((await prompter.select({ - message: "What do you want to set up?", + message: t("wizard.setup.whatSetup"), options: [ { value: "local", - label: "Local gateway (this machine)", + label: t("wizard.setup.localGateway"), hint: localProbe.ok - ? `Gateway reachable (${localUrl})` - : `No gateway detected (${localUrl})`, + ? t("wizard.setup.localGatewayReachable", { url: localUrl }) + : t("wizard.setup.localGatewayMissing", { url: localUrl }), }, { value: "remote", - label: "Remote gateway (info-only)", + label: t("wizard.setup.remoteGateway"), hint: !remoteUrl - ? "No remote URL configured yet" + ? t("wizard.setup.remoteGatewayMissing") : remoteProbe?.ok - ? `Gateway reachable (${remoteUrl})` - : `Configured but unreachable (${remoteUrl})`, + ? t("wizard.setup.remoteGatewayReachable", { url: remoteUrl }) + : t("wizard.setup.remoteGatewayUnreachable", { url: remoteUrl }), }, ], })) as OnboardMode)); @@ -554,7 +563,7 @@ export async function runSetupWizard( nextConfig = onboardHelpers.applyWizardMetadata(nextConfig, { command: "onboard", mode }); nextConfig = await writeWizardConfigFile(nextConfig); logConfigUpdated(runtime); - await prompter.outro("Remote gateway configured."); + await prompter.outro(t("wizard.setup.remoteConfigured")); return; } @@ -563,7 +572,7 @@ export async function runSetupWizard( (flow === "quickstart" ? (baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE) : await prompter.text({ - message: "Workspace directory", + message: t("wizard.setup.workspaceDirectory"), initialValue: baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE, })); @@ -602,7 +611,7 @@ export async function runSetupWizard( }); } if (authChoice === undefined) { - throw new WizardCancelledError("auth choice is required"); + throw new WizardCancelledError(t("wizard.setup.authChoiceRequired")); } if (authChoice === "custom-api-key") { @@ -717,7 +726,7 @@ export async function runSetupWizard( const settings = gateway.settings; if (opts.skipChannels ?? opts.skipProviders) { - await prompter.note("Skipping channel setup.", "Channels"); + await prompter.note(t("wizard.setup.skipChannels"), t("wizard.setup.channelsTitle")); } else { const { listChannelPlugins } = await import("../channels/plugins/index.js"); const { setupChannels } = await import("../commands/onboard-channels.js"); @@ -747,7 +756,7 @@ export async function runSetupWizard( }); if (opts.skipSearch) { - await prompter.note("Skipping search setup.", "Search"); + await prompter.note(t("wizard.setup.skipSearch"), t("wizard.setup.searchTitle")); } else { const { setupSearch } = await import("../commands/onboard-search.js"); nextConfig = await setupSearch(nextConfig, runtime, prompter, { @@ -757,7 +766,7 @@ export async function runSetupWizard( } if (opts.skipSkills) { - await prompter.note("Skipping skills setup.", "Skills"); + 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);