From 4bf67ab69879926feba333fe327fcd2fd4d4cce3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 21:18:10 +0000 Subject: [PATCH] refactor(commands): centralize shared command formatting helpers --- src/commands/channel-account-context.test.ts | 47 +++++++++ src/commands/channel-account-context.ts | 29 ++++++ src/commands/cleanup-utils.test.ts | 53 +++++++++- src/commands/cleanup-utils.ts | 44 +++++++++ src/commands/doctor-security.ts | 15 +-- src/commands/message-format.ts | 9 +- .../onboard-auth.config-shared.test.ts | 99 +++++++++++++++++++ src/commands/onboard-auth.config-shared.ts | 85 ++++++++++------ src/commands/onboard-custom.ts | 52 +++++----- src/commands/reset.ts | 23 ++--- src/commands/status.format.ts | 9 +- src/commands/status.link-channel.ts | 15 +-- src/commands/text-format.test.ts | 16 +++ src/commands/text-format.ts | 7 ++ src/commands/uninstall.ts | 18 ++-- 15 files changed, 406 insertions(+), 115 deletions(-) create mode 100644 src/commands/channel-account-context.test.ts create mode 100644 src/commands/channel-account-context.ts create mode 100644 src/commands/onboard-auth.config-shared.test.ts create mode 100644 src/commands/text-format.test.ts create mode 100644 src/commands/text-format.ts diff --git a/src/commands/channel-account-context.test.ts b/src/commands/channel-account-context.test.ts new file mode 100644 index 00000000000..9fdaadb5231 --- /dev/null +++ b/src/commands/channel-account-context.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveDefaultChannelAccountContext } from "./channel-account-context.js"; + +describe("resolveDefaultChannelAccountContext", () => { + it("uses enabled/configured defaults when hooks are missing", async () => { + const account = { token: "x" }; + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-1"], + resolveAccount: () => account, + }, + } as unknown as ChannelPlugin; + + const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig); + + expect(result.accountIds).toEqual(["acc-1"]); + expect(result.defaultAccountId).toBe("acc-1"); + expect(result.account).toBe(account); + expect(result.enabled).toBe(true); + expect(result.configured).toBe(true); + }); + + it("uses plugin enable/configure hooks", async () => { + const account = { enabled: false }; + const isEnabled = vi.fn(() => false); + const isConfigured = vi.fn(async () => false); + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-2"], + resolveAccount: () => account, + isEnabled, + isConfigured, + }, + } as unknown as ChannelPlugin; + + const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig); + + expect(isEnabled).toHaveBeenCalledWith(account, {}); + expect(isConfigured).toHaveBeenCalledWith(account, {}); + expect(result.enabled).toBe(false); + expect(result.configured).toBe(false); + }); +}); diff --git a/src/commands/channel-account-context.ts b/src/commands/channel-account-context.ts new file mode 100644 index 00000000000..99b73e62b81 --- /dev/null +++ b/src/commands/channel-account-context.ts @@ -0,0 +1,29 @@ +import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; +import type { OpenClawConfig } from "../config/config.js"; + +export type ChannelDefaultAccountContext = { + accountIds: string[]; + defaultAccountId?: string; + account: unknown; + enabled: boolean; + configured: boolean; +}; + +export async function resolveDefaultChannelAccountContext( + plugin: ChannelPlugin, + cfg: OpenClawConfig, +): Promise { + const accountIds = plugin.config.listAccountIds(cfg); + const defaultAccountId = resolveChannelDefaultAccountId({ + plugin, + cfg, + accountIds, + }); + const account = plugin.config.resolveAccount(cfg, defaultAccountId); + const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true; + const configured = plugin.config.isConfigured + ? await plugin.config.isConfigured(account, cfg) + : true; + return { accountIds, defaultAccountId, account, enabled, configured }; +} diff --git a/src/commands/cleanup-utils.test.ts b/src/commands/cleanup-utils.test.ts index 2d82753cca2..56887d20d4e 100644 --- a/src/commands/cleanup-utils.test.ts +++ b/src/commands/cleanup-utils.test.ts @@ -1,7 +1,12 @@ import path from "node:path"; -import { describe, expect, it, test } from "vitest"; +import { describe, expect, it, test, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { buildCleanupPlan } from "./cleanup-utils.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { + buildCleanupPlan, + removeStateAndLinkedPaths, + removeWorkspaceDirs, +} from "./cleanup-utils.js"; import { applyAgentDefaultPrimaryModel } from "./model-default.js"; describe("buildCleanupPlan", () => { @@ -50,3 +55,47 @@ describe("applyAgentDefaultPrimaryModel", () => { expect(result.next).toBe(cfg); }); }); + +describe("cleanup path removals", () => { + function createRuntimeMock() { + return { + log: vi.fn<(message: string) => void>(), + error: vi.fn<(message: string) => void>(), + } as unknown as RuntimeEnv & { + log: ReturnType void>>; + error: ReturnType void>>; + }; + } + + it("removes state and only linked paths outside state", async () => { + const runtime = createRuntimeMock(); + const tmpRoot = path.join(path.parse(process.cwd()).root, "tmp", "openclaw-cleanup"); + await removeStateAndLinkedPaths( + { + stateDir: path.join(tmpRoot, "state"), + configPath: path.join(tmpRoot, "state", "openclaw.json"), + oauthDir: path.join(tmpRoot, "oauth"), + configInsideState: true, + oauthInsideState: false, + }, + runtime, + { dryRun: true }, + ); + + const joinedLogs = runtime.log.mock.calls.map(([line]) => line).join("\n"); + expect(joinedLogs).toContain("[dry-run] remove /tmp/openclaw-cleanup/state"); + expect(joinedLogs).toContain("[dry-run] remove /tmp/openclaw-cleanup/oauth"); + expect(joinedLogs).not.toContain("openclaw.json"); + }); + + it("removes every workspace directory", async () => { + const runtime = createRuntimeMock(); + const workspaces = ["/tmp/openclaw-workspace-1", "/tmp/openclaw-workspace-2"]; + + await removeWorkspaceDirs(workspaces, runtime, { dryRun: true }); + + const logs = runtime.log.mock.calls.map(([line]) => line); + expect(logs).toContain("[dry-run] remove /tmp/openclaw-workspace-1"); + expect(logs).toContain("[dry-run] remove /tmp/openclaw-workspace-2"); + }); +}); diff --git a/src/commands/cleanup-utils.ts b/src/commands/cleanup-utils.ts index b3dbe39cc5e..c395c9d2b68 100644 --- a/src/commands/cleanup-utils.ts +++ b/src/commands/cleanup-utils.ts @@ -10,6 +10,14 @@ export type RemovalResult = { skipped?: boolean; }; +export type CleanupResolvedPaths = { + stateDir: string; + configPath: string; + oauthDir: string; + configInsideState: boolean; + oauthInsideState: boolean; +}; + export function collectWorkspaceDirs(cfg: OpenClawConfig | undefined): string[] { const dirs = new Set(); const defaults = cfg?.agents?.defaults; @@ -96,6 +104,42 @@ export async function removePath( } } +export async function removeStateAndLinkedPaths( + cleanup: CleanupResolvedPaths, + runtime: RuntimeEnv, + opts?: { dryRun?: boolean }, +): Promise { + await removePath(cleanup.stateDir, runtime, { + dryRun: opts?.dryRun, + label: cleanup.stateDir, + }); + if (!cleanup.configInsideState) { + await removePath(cleanup.configPath, runtime, { + dryRun: opts?.dryRun, + label: cleanup.configPath, + }); + } + if (!cleanup.oauthInsideState) { + await removePath(cleanup.oauthDir, runtime, { + dryRun: opts?.dryRun, + label: cleanup.oauthDir, + }); + } +} + +export async function removeWorkspaceDirs( + workspaceDirs: readonly string[], + runtime: RuntimeEnv, + opts?: { dryRun?: boolean }, +): Promise { + for (const workspace of workspaceDirs) { + await removePath(workspace, runtime, { + dryRun: opts?.dryRun, + label: workspace, + }); + } +} + export async function listAgentSessionDirs(stateDir: string): Promise { const root = path.join(stateDir, "agents"); try { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index cbd93e97021..6d1172d2db9 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -1,4 +1,3 @@ -import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -7,6 +6,7 @@ import { resolveGatewayAuth } from "../gateway/auth.js"; import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; import { resolveDmAllowState } from "../security/dm-policy-shared.js"; import { note } from "../terminal/note.js"; +import { resolveDefaultChannelAccountContext } from "./channel-account-context.js"; export async function noteSecurityWarnings(cfg: OpenClawConfig) { const warnings: string[] = []; @@ -133,20 +133,11 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { if (!plugin.security) { continue; } - const accountIds = plugin.config.listAccountIds(cfg); - const defaultAccountId = resolveChannelDefaultAccountId({ - plugin, - cfg, - accountIds, - }); - const account = plugin.config.resolveAccount(cfg, defaultAccountId); - const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true; + const { defaultAccountId, account, enabled, configured } = + await resolveDefaultChannelAccountContext(plugin, cfg); if (!enabled) { continue; } - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; if (!configured) { continue; } diff --git a/src/commands/message-format.ts b/src/commands/message-format.ts index 2e803a0a792..aafe570287c 100644 --- a/src/commands/message-format.ts +++ b/src/commands/message-format.ts @@ -6,14 +6,7 @@ import type { MessageActionRunResult } from "../infra/outbound/message-action-ru import { formatTargetDisplay } from "../infra/outbound/target-resolver.js"; import { renderTable } from "../terminal/table.js"; import { isRich, theme } from "../terminal/theme.js"; - -const shortenText = (value: string, maxLen: number) => { - const chars = Array.from(value); - if (chars.length <= maxLen) { - return value; - } - return `${chars.slice(0, Math.max(0, maxLen - 1)).join("")}…`; -}; +import { shortenText } from "./text-format.js"; const resolveChannelLabel = (channel: ChannelId) => getChannelPlugin(channel)?.meta.label ?? channel; diff --git a/src/commands/onboard-auth.config-shared.test.ts b/src/commands/onboard-auth.config-shared.test.ts new file mode 100644 index 00000000000..cf4f2238f2f --- /dev/null +++ b/src/commands/onboard-auth.config-shared.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import { + applyProviderConfigWithDefaultModel, + applyProviderConfigWithDefaultModels, + applyProviderConfigWithModelCatalog, +} from "./onboard-auth.config-shared.js"; + +function makeModel(id: string): ModelDefinitionConfig { + return { + id, + name: id, + contextWindow: 4096, + maxTokens: 1024, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }; +} + +describe("onboard auth provider config merges", () => { + const agentModels: Record = { + "custom/model-a": {}, + }; + + it("appends missing default models to existing provider models", () => { + const cfg = { + models: { + providers: { + custom: { + api: "openai-completions", + baseUrl: "https://old.example.com/v1", + apiKey: " test-key ", + models: [makeModel("model-a")], + }, + }, + }, + }; + + const next = applyProviderConfigWithDefaultModels(cfg, { + agentModels, + providerId: "custom", + api: "openai-completions", + baseUrl: "https://new.example.com/v1", + defaultModels: [makeModel("model-b")], + defaultModelId: "model-b", + }); + + expect(next.models?.providers?.custom?.models?.map((m) => m.id)).toEqual([ + "model-a", + "model-b", + ]); + expect(next.models?.providers?.custom?.apiKey).toBe("test-key"); + expect(next.agents?.defaults?.models).toEqual(agentModels); + }); + + it("merges model catalogs without duplicating existing model ids", () => { + const cfg = { + models: { + providers: { + custom: { + api: "openai-completions", + baseUrl: "https://example.com/v1", + models: [makeModel("model-a")], + }, + }, + }, + }; + + const next = applyProviderConfigWithModelCatalog(cfg, { + agentModels, + providerId: "custom", + api: "openai-completions", + baseUrl: "https://example.com/v1", + catalogModels: [makeModel("model-a"), makeModel("model-c")], + }); + + expect(next.models?.providers?.custom?.models?.map((m) => m.id)).toEqual([ + "model-a", + "model-c", + ]); + }); + + it("supports single default model convenience wrapper", () => { + const next = applyProviderConfigWithDefaultModel( + {}, + { + agentModels, + providerId: "custom", + api: "openai-completions", + baseUrl: "https://example.com/v1", + defaultModel: makeModel("model-z"), + }, + ); + + expect(next.models?.providers?.custom?.models?.map((m) => m.id)).toEqual(["model-z"]); + }); +}); diff --git a/src/commands/onboard-auth.config-shared.ts b/src/commands/onboard-auth.config-shared.ts index 28a167f1c8b..a417b19c36e 100644 --- a/src/commands/onboard-auth.config-shared.ts +++ b/src/commands/onboard-auth.config-shared.ts @@ -71,36 +71,28 @@ export function applyProviderConfigWithDefaultModels( defaultModelId?: string; }, ): OpenClawConfig { - const providers = { ...cfg.models?.providers } as Record; - const existingProvider = providers[params.providerId] as ModelProviderConfig | undefined; - - const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models) - ? existingProvider.models - : []; + const providerState = resolveProviderModelMergeState(cfg, params.providerId); const defaultModels = params.defaultModels; const defaultModelId = params.defaultModelId ?? defaultModels[0]?.id; const hasDefaultModel = defaultModelId - ? existingModels.some((model) => model.id === defaultModelId) + ? providerState.existingModels.some((model) => model.id === defaultModelId) : true; const mergedModels = - existingModels.length > 0 + providerState.existingModels.length > 0 ? hasDefaultModel || defaultModels.length === 0 - ? existingModels - : [...existingModels, ...defaultModels] + ? providerState.existingModels + : [...providerState.existingModels, ...defaultModels] : defaultModels; - providers[params.providerId] = buildProviderConfig({ - existingProvider, + return applyProviderConfigWithMergedModels(cfg, { + agentModels: params.agentModels, + providerId: params.providerId, + providerState, api: params.api, baseUrl: params.baseUrl, mergedModels, fallbackModels: defaultModels, }); - - return applyOnboardAuthAgentModelsAndProviders(cfg, { - agentModels: params.agentModels, - providers, - }); } export function applyProviderConfigWithDefaultModel( @@ -134,33 +126,68 @@ export function applyProviderConfigWithModelCatalog( catalogModels: ModelDefinitionConfig[]; }, ): OpenClawConfig { - const providers = { ...cfg.models?.providers } as Record; - const existingProvider = providers[params.providerId] as ModelProviderConfig | undefined; - const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models) - ? existingProvider.models - : []; - + const providerState = resolveProviderModelMergeState(cfg, params.providerId); const catalogModels = params.catalogModels; const mergedModels = - existingModels.length > 0 + providerState.existingModels.length > 0 ? [ - ...existingModels, + ...providerState.existingModels, ...catalogModels.filter( - (model) => !existingModels.some((existing) => existing.id === model.id), + (model) => !providerState.existingModels.some((existing) => existing.id === model.id), ), ] : catalogModels; - providers[params.providerId] = buildProviderConfig({ - existingProvider, + return applyProviderConfigWithMergedModels(cfg, { + agentModels: params.agentModels, + providerId: params.providerId, + providerState, api: params.api, baseUrl: params.baseUrl, mergedModels, fallbackModels: catalogModels, }); +} +type ProviderModelMergeState = { + providers: Record; + existingProvider?: ModelProviderConfig; + existingModels: ModelDefinitionConfig[]; +}; + +function resolveProviderModelMergeState( + cfg: OpenClawConfig, + providerId: string, +): ProviderModelMergeState { + const providers = { ...cfg.models?.providers } as Record; + const existingProvider = providers[providerId] as ModelProviderConfig | undefined; + const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models) + ? existingProvider.models + : []; + return { providers, existingProvider, existingModels }; +} + +function applyProviderConfigWithMergedModels( + cfg: OpenClawConfig, + params: { + agentModels: Record; + providerId: string; + providerState: ProviderModelMergeState; + api: ModelApi; + baseUrl: string; + mergedModels: ModelDefinitionConfig[]; + fallbackModels: ModelDefinitionConfig[]; + }, +): OpenClawConfig { + params.providerState.providers[params.providerId] = buildProviderConfig({ + existingProvider: params.providerState.existingProvider, + api: params.api, + baseUrl: params.baseUrl, + mergedModels: params.mergedModels, + fallbackModels: params.fallbackModels, + }); return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: params.agentModels, - providers, + providers: params.providerState.providers, }); } diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index f9e8ae84b6e..aff71ce7f3d 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -383,6 +383,26 @@ async function promptCustomApiModelId(prompter: WizardPrompter): Promise ).trim(); } +async function applyCustomApiRetryChoice(params: { + prompter: WizardPrompter; + retryChoice: CustomApiRetryChoice; + current: { baseUrl: string; apiKey: string; modelId: string }; +}): Promise<{ baseUrl: string; apiKey: string; modelId: string }> { + let { baseUrl, apiKey, modelId } = params.current; + if (params.retryChoice === "baseUrl" || params.retryChoice === "both") { + const retryInput = await promptBaseUrlAndKey({ + prompter: params.prompter, + initialBaseUrl: baseUrl, + }); + baseUrl = retryInput.baseUrl; + apiKey = retryInput.apiKey; + } + if (params.retryChoice === "model" || params.retryChoice === "both") { + modelId = await promptCustomApiModelId(params.prompter); + } + return { baseUrl, apiKey, modelId }; +} + function resolveProviderApi( compatibility: CustomApiCompatibility, ): "openai-completions" | "anthropic-messages" { @@ -618,17 +638,11 @@ export async function promptCustomApiConfig(params: { "Endpoint detection", ); const retryChoice = await promptCustomApiRetryChoice(prompter); - if (retryChoice === "baseUrl" || retryChoice === "both") { - const retryInput = await promptBaseUrlAndKey({ - prompter, - initialBaseUrl: baseUrl, - }); - baseUrl = retryInput.baseUrl; - apiKey = retryInput.apiKey; - } - if (retryChoice === "model" || retryChoice === "both") { - modelId = await promptCustomApiModelId(prompter); - } + ({ baseUrl, apiKey, modelId } = await applyCustomApiRetryChoice({ + prompter, + retryChoice, + current: { baseUrl, apiKey, modelId }, + })); continue; } } @@ -653,17 +667,11 @@ export async function promptCustomApiConfig(params: { verifySpinner.stop(`Verification failed: ${formatVerificationError(result.error)}`); } const retryChoice = await promptCustomApiRetryChoice(prompter); - if (retryChoice === "baseUrl" || retryChoice === "both") { - const retryInput = await promptBaseUrlAndKey({ - prompter, - initialBaseUrl: baseUrl, - }); - baseUrl = retryInput.baseUrl; - apiKey = retryInput.apiKey; - } - if (retryChoice === "model" || retryChoice === "both") { - modelId = await promptCustomApiModelId(prompter); - } + ({ baseUrl, apiKey, modelId } = await applyCustomApiRetryChoice({ + prompter, + retryChoice, + current: { baseUrl, apiKey, modelId }, + })); if (compatibilityChoice === "unknown") { compatibility = null; } diff --git a/src/commands/reset.ts b/src/commands/reset.ts index 6cd8ba3212f..1f9ba9a7997 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -6,7 +6,12 @@ import type { RuntimeEnv } from "../runtime.js"; import { selectStyled } from "../terminal/prompt-select-styled.js"; import { stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js"; import { resolveCleanupPlanFromDisk } from "./cleanup-plan.js"; -import { listAgentSessionDirs, removePath } from "./cleanup-utils.js"; +import { + listAgentSessionDirs, + removePath, + removeStateAndLinkedPaths, + removeWorkspaceDirs, +} from "./cleanup-utils.js"; export type ResetScope = "config" | "config+creds+sessions" | "full"; @@ -129,16 +134,12 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) { } if (scope === "full") { - await removePath(stateDir, runtime, { dryRun, label: stateDir }); - if (!configInsideState) { - await removePath(configPath, runtime, { dryRun, label: configPath }); - } - if (!oauthInsideState) { - await removePath(oauthDir, runtime, { dryRun, label: oauthDir }); - } - for (const workspace of workspaceDirs) { - await removePath(workspace, runtime, { dryRun, label: workspace }); - } + await removeStateAndLinkedPaths( + { stateDir, configPath, oauthDir, configInsideState, oauthInsideState }, + runtime, + { dryRun }, + ); + await removeWorkspaceDirs(workspaceDirs, runtime, { dryRun }); runtime.log(`Next: ${formatCliCommand("openclaw onboard --install-daemon")}`); return; } diff --git a/src/commands/status.format.ts b/src/commands/status.format.ts index c62a23e7212..48f6927b671 100644 --- a/src/commands/status.format.ts +++ b/src/commands/status.format.ts @@ -1,6 +1,7 @@ import { formatDurationPrecise } from "../infra/format-time/format-duration.ts"; import { formatRuntimeStatusWithDetails } from "../infra/runtime-status.ts"; import type { SessionStatus } from "./status.types.js"; +export { shortenText } from "./text-format.js"; export const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; @@ -12,14 +13,6 @@ export const formatDuration = (ms: number | null | undefined) => { return formatDurationPrecise(ms, { decimals: 1 }); }; -export const shortenText = (value: string, maxLen: number) => { - const chars = Array.from(value); - if (chars.length <= maxLen) { - return value; - } - return `${chars.slice(0, Math.max(0, maxLen - 1)).join("")}…`; -}; - export const formatTokensCompact = ( sess: Pick< SessionStatus, diff --git a/src/commands/status.link-channel.ts b/src/commands/status.link-channel.ts index cea7b8feb91..2ee0eee4f2e 100644 --- a/src/commands/status.link-channel.ts +++ b/src/commands/status.link-channel.ts @@ -1,7 +1,7 @@ -import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveDefaultChannelAccountContext } from "./channel-account-context.js"; export type LinkChannelContext = { linked: boolean; @@ -15,17 +15,8 @@ export async function resolveLinkChannelContext( cfg: OpenClawConfig, ): Promise { for (const plugin of listChannelPlugins()) { - const accountIds = plugin.config.listAccountIds(cfg); - const defaultAccountId = resolveChannelDefaultAccountId({ - plugin, - cfg, - accountIds, - }); - const account = plugin.config.resolveAccount(cfg, defaultAccountId); - const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true; - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; + const { defaultAccountId, account, enabled, configured } = + await resolveDefaultChannelAccountContext(plugin, cfg); const snapshot = plugin.config.describeAccount ? plugin.config.describeAccount(account, cfg) : ({ diff --git a/src/commands/text-format.test.ts b/src/commands/text-format.test.ts new file mode 100644 index 00000000000..38288f85ede --- /dev/null +++ b/src/commands/text-format.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { shortenText } from "./text-format.js"; + +describe("shortenText", () => { + it("returns original text when it fits", () => { + expect(shortenText("openclaw", 16)).toBe("openclaw"); + }); + + it("truncates and appends ellipsis when over limit", () => { + expect(shortenText("openclaw-status-output", 10)).toBe("openclaw-…"); + }); + + it("counts multi-byte characters correctly", () => { + expect(shortenText("hello🙂world", 7)).toBe("hello🙂…"); + }); +}); diff --git a/src/commands/text-format.ts b/src/commands/text-format.ts new file mode 100644 index 00000000000..880cf574fcb --- /dev/null +++ b/src/commands/text-format.ts @@ -0,0 +1,7 @@ +export const shortenText = (value: string, maxLen: number) => { + const chars = Array.from(value); + if (chars.length <= maxLen) { + return value; + } + return `${chars.slice(0, Math.max(0, maxLen - 1)).join("")}…`; +}; diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 59691653f99..aa91a321d00 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -6,7 +6,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js"; import { resolveHomeDir } from "../utils.js"; import { resolveCleanupPlanFromDisk } from "./cleanup-plan.js"; -import { removePath } from "./cleanup-utils.js"; +import { removePath, removeStateAndLinkedPaths, removeWorkspaceDirs } from "./cleanup-utils.js"; type UninstallScope = "service" | "state" | "workspace" | "app"; @@ -164,19 +164,15 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio } if (scopes.has("state")) { - await removePath(stateDir, runtime, { dryRun, label: stateDir }); - if (!configInsideState) { - await removePath(configPath, runtime, { dryRun, label: configPath }); - } - if (!oauthInsideState) { - await removePath(oauthDir, runtime, { dryRun, label: oauthDir }); - } + await removeStateAndLinkedPaths( + { stateDir, configPath, oauthDir, configInsideState, oauthInsideState }, + runtime, + { dryRun }, + ); } if (scopes.has("workspace")) { - for (const workspace of workspaceDirs) { - await removePath(workspace, runtime, { dryRun, label: workspace }); - } + await removeWorkspaceDirs(workspaceDirs, runtime, { dryRun }); } if (scopes.has("app")) {