mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 15:54:47 +00:00
feat(wizard): localize onboarding flows
This commit is contained in:
committed by
Peter Steinberger
parent
d8ae3ec4c8
commit
bfc674876d
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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<CustomApiRetryChoice> {
|
||||
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<Cus
|
||||
async function promptCustomApiModelId(prompter: WizardPrompter): Promise<string> {
|
||||
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"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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__"],
|
||||
|
||||
@@ -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 <name>")}`,
|
||||
` ${formatCliCommand("openclaw hooks disable <name>")}`,
|
||||
].join("\n"),
|
||||
"Hooks Configured",
|
||||
t("wizard.hooks.configuredTitle"),
|
||||
);
|
||||
|
||||
return next;
|
||||
|
||||
@@ -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 <user>@${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();
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<InstallChoice>({
|
||||
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) {
|
||||
|
||||
48
src/flows/channel-setup.prompts.test.ts
Normal file
48
src/flows/channel-setup.prompts.test.ts
Normal file
@@ -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<WizardPrompter["note"]>(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" }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<WizardSelectOption<ConfiguredChannelAction>> = [
|
||||
{
|
||||
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} <code>`)}`,
|
||||
`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} <code>`),
|
||||
}),
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<T extends { selectionHint?: string; statusLines: string[] }>(
|
||||
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 <channel> <code>")}`,
|
||||
'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 <channel> <code>"),
|
||||
}),
|
||||
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string>();
|
||||
// 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<PromptDefaultModelResult> {
|
||||
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<ReturnType<typeof loadModelCatalog>>;
|
||||
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<ReturnType<typeof loadModelCatalog>>;
|
||||
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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { setupWizardShellCompletion } from "./setup.completion.js";
|
||||
|
||||
async function withLocale(locale: string, run: () => Promise<void>): Promise<void> {
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof vi.fn>).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();
|
||||
|
||||
|
||||
@@ -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<OnboardSearchModule> | undefined;
|
||||
const HATCH_TUI_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
function getLocalizedGatewayDaemonRuntimeOptions() {
|
||||
return GATEWAY_DAEMON_RUNTIME_OPTIONS.map((option) => ({
|
||||
hint:
|
||||
option.value === "node"
|
||||
? t("wizard.finalize.daemonRuntimeNodeHint")
|
||||
: (option.hint ?? undefined),
|
||||
label: option.value === "node" ? t("wizard.finalize.daemonRuntimeNode") : option.label,
|
||||
value: option.value,
|
||||
}));
|
||||
}
|
||||
|
||||
function loadOnboardSearchModule(): Promise<OnboardSearchModule> {
|
||||
onboardSearchModulePromise ??= import("../commands/onboard-search.js");
|
||||
return onboardSearchModulePromise;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<GatewayWizardSettings["bind"]>({
|
||||
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<GatewayWizardSettings["tailscaleMode"]>({
|
||||
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,
|
||||
}),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} <value>\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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user