refactor: dedupe plugin string list helpers

This commit is contained in:
Peter Steinberger
2026-04-07 04:13:30 +01:00
parent 7dc085890e
commit 80a37ef32a
8 changed files with 80 additions and 76 deletions

View File

@@ -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<string, unknown> | 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(

View File

@@ -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<string, unknown> | 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<string>();
@@ -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)"}`;
});

View File

@@ -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<string, unknown> => (isRecord(value) ? value : {});
function summarizeSources(sources: Array<string | undefined>): {
label: string;
parts: string[];

View File

@@ -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<string, ReturnType<typeof createJiti>>();
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<string, PluginManifestChannelConfig> | undefined {
const channelIds = normalizeStringList(params.manifest.channels);
const channelIds = normalizeBundledPluginStringList(params.manifest.channels);
const existingChannelConfigs: Record<string, PluginManifestChannelConfig> =
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<string, PluginConfigUiHint> | undefined =
surface?.uiHints || existing?.uiHints
? {

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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<string, string[]> | undefined {
if (!isRecord(value)) {
return undefined;
@@ -180,7 +174,7 @@ function normalizeStringListRecord(value: unknown): Record<string, string[]> | 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);

View File

@@ -6,6 +6,25 @@ export function normalizeStringEntriesLower(list?: ReadonlyArray<unknown>) {
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) {