From 80a37ef32aba66e122f79c36f627afa4b42aa927 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 04:13:30 +0100 Subject: [PATCH] refactor: dedupe plugin string list helpers --- src/commands/doctor-plugin-manifests.ts | 12 +--- src/commands/health.ts | 29 +++++----- src/commands/status-all/channels.ts | 4 +- .../bundled-channel-config-metadata.ts | 16 ++---- src/plugins/bundled-plugin-metadata.ts | 10 +--- src/plugins/bundled-plugin-scan.ts | 10 ++++ src/plugins/manifest.ts | 56 +++++++++---------- src/shared/string-normalization.ts | 19 +++++++ 8 files changed, 80 insertions(+), 76 deletions(-) diff --git a/src/commands/doctor-plugin-manifests.ts b/src/commands/doctor-plugin-manifests.ts index bb9b248486a..218f98283e6 100644 --- a/src/commands/doctor-plugin-manifests.ts +++ b/src/commands/doctor-plugin-manifests.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { z } from "zod"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import type { RuntimeEnv } from "../runtime.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; import { safeParseJsonWithSchema, safeParseWithSchema } from "../utils/zod-parse.js"; @@ -22,13 +23,6 @@ type LegacyManifestContractMigration = { const JsonRecordSchema = z.record(z.string(), z.unknown()); -function normalizeStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); -} - function readManifestJson(manifestPath: string): Record | null { try { return safeParseJsonWithSchema(JsonRecordSchema, fs.readFileSync(manifestPath, "utf-8")); @@ -50,8 +44,8 @@ function buildLegacyManifestContractMigration(params: { if (!(key in params.raw)) { continue; } - const legacyValues = normalizeStringList(params.raw[key]); - const contractValues = normalizeStringList(nextContracts[key]); + const legacyValues = normalizeTrimmedStringList(params.raw[key]); + const contractValues = normalizeTrimmedStringList(nextContracts[key]); if (legacyValues.length > 0 && contractValues.length === 0) { nextContracts[key] = legacyValues; changeLines.push( diff --git a/src/commands/health.ts b/src/commands/health.ts index 2a2cf61db74..84902737616 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -18,9 +18,9 @@ import { import { buildChannelAccountBindings, resolvePreferredAccountId } from "../routing/bindings.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; +import { asNullableRecord } from "../shared/record-coerce.js"; import { styleHealthChannelLine } from "../terminal/health-style.js"; import { isRich } from "../terminal/theme.js"; -import { isRecord } from "../utils.js"; import { logGatewayConnectionDetails } from "./status.gateway-connection.js"; export type ChannelAccountHealthSummary = { @@ -164,9 +164,6 @@ const buildSessionSummary = (storePath: string) => { } satisfies HealthSummary["sessions"]; }; -const asRecord = (value: unknown): Record | null => - isRecord(value) ? value : null; - async function inspectHealthAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) { return ( plugin.config.inspectAccount?.(cfg, accountId) ?? @@ -179,7 +176,7 @@ async function inspectHealthAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, } function readBooleanField(value: unknown, key: string): boolean | undefined { - const record = asRecord(value); + const record = asNullableRecord(value); if (!record) { return undefined; } @@ -246,7 +243,7 @@ async function resolveHealthAccountContext(params: { } const formatProbeLine = (probe: unknown, opts: { botUsernames?: string[] } = {}): string | null => { - const record = asRecord(probe); + const record = asNullableRecord(probe); if (!record) { return null; } @@ -257,9 +254,9 @@ const formatProbeLine = (probe: unknown, opts: { botUsernames?: string[] } = {}) const elapsedMs = typeof record.elapsedMs === "number" ? record.elapsedMs : null; const status = typeof record.status === "number" ? record.status : null; const error = typeof record.error === "string" ? record.error : null; - const bot = asRecord(record.bot); + const bot = asNullableRecord(record.bot); const botUsername = bot && typeof bot.username === "string" ? bot.username : null; - const webhook = asRecord(record.webhook); + const webhook = asNullableRecord(record.webhook); const webhookUrl = webhook && typeof webhook.url === "string" ? webhook.url : null; const usernames = new Set(); @@ -293,7 +290,7 @@ const formatProbeLine = (probe: unknown, opts: { botUsernames?: string[] } = {}) }; const formatAccountProbeTiming = (summary: ChannelAccountHealthSummary): string | null => { - const probe = asRecord(summary.probe); + const probe = asNullableRecord(summary.probe); if (!probe) { return null; } @@ -304,7 +301,7 @@ const formatAccountProbeTiming = (summary: ChannelAccountHealthSummary): string } const accountId = summary.accountId || "default"; - const botRecord = asRecord(probe.bot); + const botRecord = asNullableRecord(probe.bot); const botUsername = botRecord && typeof botRecord.username === "string" ? botRecord.username : null; const handle = botUsername ? `@${botUsername}` : accountId; @@ -314,7 +311,7 @@ const formatAccountProbeTiming = (summary: ChannelAccountHealthSummary): string }; const isProbeFailure = (summary: ChannelAccountHealthSummary): boolean => { - const probe = asRecord(summary.probe); + const probe = asNullableRecord(summary.probe); if (!probe) { return false; } @@ -359,8 +356,8 @@ export const formatHealthChannelLines = ( const botUsernames = listSummaries ? listSummaries .map((account) => { - const probeRecord = asRecord(account.probe); - const bot = probeRecord ? asRecord(probeRecord.bot) : null; + const probeRecord = asNullableRecord(account.probe); + const bot = probeRecord ? asNullableRecord(probeRecord.bot) : null; return bot && typeof bot.username === "string" ? bot.username : null; }) .filter((value): value is string => Boolean(value)) @@ -668,7 +665,7 @@ export async function healthCommand( cfg, accountId, }); - const record = asRecord(account); + const record = asNullableRecord(account); const tokenSource = record && typeof record.tokenSource === "string" ? record.tokenSource : undefined; runtime.log( @@ -690,8 +687,8 @@ export async function healthCommand( for (const [channelId, channelSummary] of Object.entries(summary.channels ?? {})) { const accounts = channelSummary.accounts ?? {}; const probes = Object.entries(accounts).map(([accountId, accountSummary]) => { - const probe = asRecord(accountSummary.probe); - const bot = probe ? asRecord(probe.bot) : null; + const probe = asNullableRecord(accountSummary.probe); + const bot = probe ? asNullableRecord(probe.bot) : null; const username = bot && typeof bot.username === "string" ? bot.username : null; return `${accountId}=${username ?? "(no bot)"}`; }); diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index a365508fa32..a2511235696 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -19,7 +19,7 @@ import type { import { inspectReadOnlyChannelAccount } from "../../channels/read-only-account-inspect.js"; import type { OpenClawConfig } from "../../config/config.js"; import { sha256HexPrefix } from "../../logging/redact-identifier.js"; -import { isRecord } from "../../utils.js"; +import { asRecord } from "../../shared/record-coerce.js"; import { formatTimeAgo } from "./format.js"; export type ChannelRow = { @@ -45,8 +45,6 @@ type ResolvedChannelAccountRowParams = { accountId: string; }; -const asRecord = (value: unknown): Record => (isRecord(value) ? value : {}); - function summarizeSources(sources: Array): { label: string; parts: string[]; diff --git a/src/plugins/bundled-channel-config-metadata.ts b/src/plugins/bundled-channel-config-metadata.ts index 9b6972b9020..4a69ed378eb 100644 --- a/src/plugins/bundled-channel-config-metadata.ts +++ b/src/plugins/bundled-channel-config-metadata.ts @@ -3,7 +3,10 @@ import path from "node:path"; import { createJiti } from "jiti"; import { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.plugin.js"; -import { trimBundledPluginString } from "./bundled-plugin-scan.js"; +import { + normalizeBundledPluginStringList, + trimBundledPluginString, +} from "./bundled-plugin-scan.js"; import type { OpenClawPackageManifest, PluginManifest, @@ -35,13 +38,6 @@ type ChannelConfigSurface = { const jitiLoaders = new Map>(); -function normalizeStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.map((entry) => trimBundledPluginString(entry) ?? "").filter(Boolean); -} - function isBuiltChannelConfigSchema(value: unknown): value is ChannelConfigSurface { if (!value || typeof value !== "object") { return false; @@ -138,7 +134,7 @@ export function collectBundledChannelConfigs(params: { manifest: PluginManifest; packageManifest?: OpenClawPackageManifest; }): Record | undefined { - const channelIds = normalizeStringList(params.manifest.channels); + const channelIds = normalizeBundledPluginStringList(params.manifest.channels); const existingChannelConfigs: Record = params.manifest.channelConfigs && Object.keys(params.manifest.channelConfigs).length > 0 ? { ...params.manifest.channelConfigs } @@ -153,7 +149,7 @@ export function collectBundledChannelConfigs(params: { for (const channelId of channelIds) { const existing = existingChannelConfigs[channelId]; const channelMeta = resolvePackageChannelMeta(params.packageManifest, channelId); - const preferOver = normalizeStringList(channelMeta?.preferOver); + const preferOver = normalizeBundledPluginStringList(channelMeta?.preferOver); const uiHints: Record | undefined = surface?.uiHints || existing?.uiHints ? { diff --git a/src/plugins/bundled-plugin-metadata.ts b/src/plugins/bundled-plugin-metadata.ts index ef5a2205ecd..63283cd48b0 100644 --- a/src/plugins/bundled-plugin-metadata.ts +++ b/src/plugins/bundled-plugin-metadata.ts @@ -6,6 +6,7 @@ import { collectBundledPluginPublicSurfaceArtifacts, collectBundledPluginRuntimeSidecarArtifacts, deriveBundledPluginIdHint, + normalizeBundledPluginStringList, rewriteBundledPluginEntryToBuiltPath, resolveBundledPluginScanDir, trimBundledPluginString, @@ -54,13 +55,6 @@ export function clearBundledPluginMetadataCache(): void { bundledPluginMetadataCache.clear(); } -function normalizeStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.map((entry) => trimBundledPluginString(entry) ?? "").filter(Boolean); -} - function readPackageManifest(pluginDir: string): PackageManifest | undefined { const packagePath = path.join(pluginDir, "package.json"); if (!fs.existsSync(packagePath)) { @@ -100,7 +94,7 @@ function collectBundledPluginMetadataForPackageRoot( const packageJson = readPackageManifest(pluginDir); const packageManifest = getPackageManifestMetadata(packageJson); - const extensions = normalizeStringList(packageManifest?.extensions); + const extensions = normalizeBundledPluginStringList(packageManifest?.extensions); if (extensions.length === 0) { continue; } diff --git a/src/plugins/bundled-plugin-scan.ts b/src/plugins/bundled-plugin-scan.ts index 47b06c050e4..40d00f1f3c3 100644 --- a/src/plugins/bundled-plugin-scan.ts +++ b/src/plugins/bundled-plugin-scan.ts @@ -13,6 +13,16 @@ export function trimBundledPluginString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } +export function normalizeBundledPluginStringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((entry) => { + const normalized = trimBundledPluginString(entry); + return normalized ? [normalized] : []; + }); +} + export function rewriteBundledPluginEntryToBuiltPath( entry: string | undefined, ): string | undefined { diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index a8ea6ff0417..1207d2c3000 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -4,6 +4,7 @@ import JSON5 from "json5"; import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.plugin.js"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; import { isRecord } from "../utils.js"; import type { PluginConfigUiHint, PluginKind } from "./types.js"; @@ -163,13 +164,6 @@ export type PluginManifestLoadResult = | { ok: true; manifest: PluginManifest; manifestPath: string } | { ok: false; error: string; manifestPath: string }; -function normalizeStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); -} - function normalizeStringListRecord(value: unknown): Record | undefined { if (!isRecord(value)) { return undefined; @@ -180,7 +174,7 @@ function normalizeStringListRecord(value: unknown): Record | u if (!providerId) { continue; } - const values = normalizeStringList(rawValues); + const values = normalizeTrimmedStringList(rawValues); if (values.length === 0) { continue; } @@ -194,17 +188,19 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u return undefined; } - const memoryEmbeddingProviders = normalizeStringList(value.memoryEmbeddingProviders); - const speechProviders = normalizeStringList(value.speechProviders); - const realtimeTranscriptionProviders = normalizeStringList(value.realtimeTranscriptionProviders); - const realtimeVoiceProviders = normalizeStringList(value.realtimeVoiceProviders); - const mediaUnderstandingProviders = normalizeStringList(value.mediaUnderstandingProviders); - const imageGenerationProviders = normalizeStringList(value.imageGenerationProviders); - const videoGenerationProviders = normalizeStringList(value.videoGenerationProviders); - const musicGenerationProviders = normalizeStringList(value.musicGenerationProviders); - const webFetchProviders = normalizeStringList(value.webFetchProviders); - const webSearchProviders = normalizeStringList(value.webSearchProviders); - const tools = normalizeStringList(value.tools); + const memoryEmbeddingProviders = normalizeTrimmedStringList(value.memoryEmbeddingProviders); + const speechProviders = normalizeTrimmedStringList(value.speechProviders); + const realtimeTranscriptionProviders = normalizeTrimmedStringList( + value.realtimeTranscriptionProviders, + ); + const realtimeVoiceProviders = normalizeTrimmedStringList(value.realtimeVoiceProviders); + const mediaUnderstandingProviders = normalizeTrimmedStringList(value.mediaUnderstandingProviders); + const imageGenerationProviders = normalizeTrimmedStringList(value.imageGenerationProviders); + const videoGenerationProviders = normalizeTrimmedStringList(value.videoGenerationProviders); + const musicGenerationProviders = normalizeTrimmedStringList(value.musicGenerationProviders); + const webFetchProviders = normalizeTrimmedStringList(value.webFetchProviders); + const webSearchProviders = normalizeTrimmedStringList(value.webSearchProviders); + const tools = normalizeTrimmedStringList(value.tools); const contracts = { ...(memoryEmbeddingProviders.length > 0 ? { memoryEmbeddingProviders } : {}), ...(speechProviders.length > 0 ? { speechProviders } : {}), @@ -309,8 +305,8 @@ function normalizeManifestModelSupport(value: unknown): PluginManifestModelSuppo return undefined; } - const modelPrefixes = normalizeStringList(value.modelPrefixes); - const modelPatterns = normalizeStringList(value.modelPatterns); + const modelPrefixes = normalizeTrimmedStringList(value.modelPrefixes); + const modelPatterns = normalizeTrimmedStringList(value.modelPatterns); const modelSupport = { ...(modelPrefixes.length > 0 ? { modelPrefixes } : {}), ...(modelPatterns.length > 0 ? { modelPatterns } : {}), @@ -346,7 +342,7 @@ function normalizeProviderAuthChoices( entry.assistantVisibility === "manual-only" || entry.assistantVisibility === "visible" ? entry.assistantVisibility : undefined; - const deprecatedChoiceIds = normalizeStringList(entry.deprecatedChoiceIds); + const deprecatedChoiceIds = normalizeTrimmedStringList(entry.deprecatedChoiceIds); const groupId = typeof entry.groupId === "string" ? entry.groupId.trim() : ""; const groupLabel = typeof entry.groupLabel === "string" ? entry.groupLabel.trim() : ""; const groupHint = typeof entry.groupHint === "string" ? entry.groupHint.trim() : ""; @@ -355,7 +351,7 @@ function normalizeProviderAuthChoices( const cliOption = typeof entry.cliOption === "string" ? entry.cliOption.trim() : ""; const cliDescription = typeof entry.cliDescription === "string" ? entry.cliDescription.trim() : ""; - const onboardingScopes = normalizeStringList(entry.onboardingScopes).filter( + const onboardingScopes = normalizeTrimmedStringList(entry.onboardingScopes).filter( (scope): scope is PluginManifestOnboardingScope => scope === "text-inference" || scope === "image-generation", ); @@ -406,7 +402,7 @@ function normalizeChannelConfigs( : undefined; const label = typeof rawEntry.label === "string" ? rawEntry.label.trim() : ""; const description = typeof rawEntry.description === "string" ? rawEntry.description.trim() : ""; - const preferOver = normalizeStringList(rawEntry.preferOver); + const preferOver = normalizeTrimmedStringList(rawEntry.preferOver); normalized[channelId] = { schema, ...(uiHints ? { uiHints } : {}), @@ -490,21 +486,21 @@ export function loadPluginManifest( const kind = parsePluginKind(raw.kind); const enabledByDefault = raw.enabledByDefault === true; - const legacyPluginIds = normalizeStringList(raw.legacyPluginIds); - const autoEnableWhenConfiguredProviders = normalizeStringList( + const legacyPluginIds = normalizeTrimmedStringList(raw.legacyPluginIds); + const autoEnableWhenConfiguredProviders = normalizeTrimmedStringList( raw.autoEnableWhenConfiguredProviders, ); const name = typeof raw.name === "string" ? raw.name.trim() : undefined; const description = typeof raw.description === "string" ? raw.description.trim() : undefined; const version = typeof raw.version === "string" ? raw.version.trim() : undefined; - const channels = normalizeStringList(raw.channels); - const providers = normalizeStringList(raw.providers); + const channels = normalizeTrimmedStringList(raw.channels); + const providers = normalizeTrimmedStringList(raw.providers); const modelSupport = normalizeManifestModelSupport(raw.modelSupport); - const cliBackends = normalizeStringList(raw.cliBackends); + const cliBackends = normalizeTrimmedStringList(raw.cliBackends); const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars); const channelEnvVars = normalizeStringListRecord(raw.channelEnvVars); const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices); - const skills = normalizeStringList(raw.skills); + const skills = normalizeTrimmedStringList(raw.skills); const contracts = normalizeManifestContracts(raw.contracts); const configContracts = normalizeManifestConfigContracts(raw.configContracts); const channelConfigs = normalizeChannelConfigs(raw.channelConfigs); diff --git a/src/shared/string-normalization.ts b/src/shared/string-normalization.ts index 2c117390b86..733b5e02166 100644 --- a/src/shared/string-normalization.ts +++ b/src/shared/string-normalization.ts @@ -6,6 +6,25 @@ export function normalizeStringEntriesLower(list?: ReadonlyArray) { return normalizeStringEntries(list).map((entry) => entry.toLowerCase()); } +export function normalizeTrimmedStringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((entry) => + typeof entry === "string" && entry.trim() ? [entry.trim()] : [], + ); +} + +export function normalizeSingleOrTrimmedStringList(value: unknown): string[] { + if (Array.isArray(value)) { + return normalizeTrimmedStringList(value); + } + if (typeof value === "string" && value.trim()) { + return [value.trim()]; + } + return []; +} + export function normalizeHyphenSlug(raw?: string | null) { const trimmed = raw?.trim().toLowerCase() ?? ""; if (!trimmed) {