From 2ab8cbc66b3b0a7a6cf27e5afdbdc0cdb3f1efce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 13 Jul 2026 07:57:23 -0700 Subject: [PATCH] refactor(config): simplify channel schema loading (#106462) --- ...enerate-bundled-channel-config-metadata.ts | 2 +- scripts/load-channel-config-surface.ts | 167 ++------------ .../load-channel-config-surface.test.ts | 210 +++++------------- 3 files changed, 70 insertions(+), 309 deletions(-) diff --git a/scripts/generate-bundled-channel-config-metadata.ts b/scripts/generate-bundled-channel-config-metadata.ts index 8466600ac26f..62c95f2a5f12 100644 --- a/scripts/generate-bundled-channel-config-metadata.ts +++ b/scripts/generate-bundled-channel-config-metadata.ts @@ -253,7 +253,7 @@ async function collectBundledChannelConfigMetadata(params?: { repoRoot?: string if (!modulePath) { continue; } - const surface = await loadChannelConfigSurfaceModule(modulePath, { repoRoot }); + const surface = await loadChannelConfigSurfaceModule(modulePath); if (!surface?.schema) { continue; } diff --git a/scripts/load-channel-config-surface.ts b/scripts/load-channel-config-surface.ts index 1baa9caf5048..b9238bcdcdd1 100644 --- a/scripts/load-channel-config-surface.ts +++ b/scripts/load-channel-config-surface.ts @@ -1,41 +1,13 @@ // Load Channel Config Surface script supports OpenClaw repository automation. -import { spawnSync } from "node:child_process"; -import { createRequire } from "node:module"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import type { createJiti } from "jiti"; +import { pathToFileURL } from "node:url"; +import { createJiti } from "jiti"; import { buildChannelConfigSchema } from "../src/channels/plugins/config-schema.js"; import { buildPluginLoaderAliasMap, buildPluginLoaderJitiOptions, } from "../src/plugins/sdk-alias.js"; -type CreateJiti = typeof createJiti; - -const jitiFactoryOverrideKey = Symbol.for("openclaw.channelConfigSurfaceJitiFactoryOverride"); -const requireForJiti = createRequire(import.meta.url); -let createJitiLoaderFactory: CreateJiti | undefined; - -function loadCreateJitiLoaderFactory(): CreateJiti { - const override = ( - globalThis as typeof globalThis & { - [jitiFactoryOverrideKey]?: CreateJiti; - } - )[jitiFactoryOverrideKey]; - if (override) { - return override; - } - if (createJitiLoaderFactory) { - return createJitiLoaderFactory; - } - const loaded = requireForJiti("jiti") as { createJiti?: CreateJiti }; - if (typeof loaded.createJiti !== "function") { - throw new Error("jiti module did not export createJiti"); - } - createJitiLoaderFactory = loaded.createJiti; - return createJitiLoaderFactory; -} - function isBuiltChannelConfigSchema( value: unknown, ): value is { schema: Record; uiHints?: Record } { @@ -76,132 +48,21 @@ function resolveConfigSchemaExport( return null; } -function resolveRepoRoot(): string { - return path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -} - -function isMissingExecutableError(error: unknown): boolean { - if (!error || typeof error !== "object") { - return false; - } - return "code" in error && error.code === "ENOENT"; -} - export async function loadChannelConfigSurfaceModule( modulePath: string, - options?: { repoRoot?: string }, ): Promise<{ schema: Record; uiHints?: Record } | null> { - const repoRoot = options?.repoRoot ?? resolveRepoRoot(); - const loaderRepoRoot = resolveRepoRoot(); - const bunBuildChannelConfigSchemaUrl = pathToFileURL( - path.join(loaderRepoRoot, "src/channels/plugins/config-schema.ts"), - ).href; - const loadViaBun = (candidatePath: string) => { - const script = ` - import { pathToFileURL } from "node:url"; - const { buildChannelConfigSchema } = await import(${JSON.stringify(bunBuildChannelConfigSchemaUrl)}); - const modulePath = process.env.OPENCLAW_CONFIG_SURFACE_MODULE; - if (!modulePath) { - throw new Error("missing OPENCLAW_CONFIG_SURFACE_MODULE"); - } - const imported = await import(pathToFileURL(modulePath).href); - const isBuilt = (value) => Boolean( - value && - typeof value === "object" && - value.schema && - typeof value.schema === "object" - ); - const resolve = (mod) => { - for (const [name, value] of Object.entries(mod)) { - if (name.endsWith("ChannelConfigSchema") && isBuilt(value)) return value; - } - for (const [name, value] of Object.entries(mod)) { - if (!name.endsWith("ConfigSchema") || name.endsWith("AccountConfigSchema")) continue; - if (isBuilt(value)) return value; - if (value && typeof value === "object") return buildChannelConfigSchema(value); - } - for (const value of Object.values(mod)) { - if (isBuilt(value)) return value; - } - return null; - }; - process.stdout.write(JSON.stringify(resolve(imported))); - `; - const result = spawnSync("bun", ["-e", script], { - cwd: repoRoot, - encoding: "utf8", - env: { - ...process.env, - OPENCLAW_CONFIG_SURFACE_MODULE: path.resolve(candidatePath), - }, - }); - if (result.error) { - if (isMissingExecutableError(result.error)) { - return null; - } - throw result.error; - } - if (result.status !== 0) { - throw new Error(result.stderr || result.stdout || `bun loader failed for ${candidatePath}`); - } - return JSON.parse(result.stdout || "null") as { - schema: Record; - uiHints?: Record; - } | null; - }; - const loadViaJiti = (candidatePath: string) => { - const resolvedPath = path.resolve(candidatePath); - const aliasMap = buildPluginLoaderAliasMap(resolvedPath, "", undefined, "src"); - const jiti = loadCreateJitiLoaderFactory()(import.meta.url, { - ...buildPluginLoaderJitiOptions(aliasMap), - interopDefault: true, - tryNative: false, - moduleCache: false, - fsCache: false, - }); - return jiti(resolvedPath) as Record; - }; - const loadViaNativeImport = async (candidatePath: string) => { - const imported = (await import(pathToFileURL(path.resolve(candidatePath)).href)) as Record< - string, - unknown - >; - return resolveConfigSchemaExport(imported); - }; - const loadFromPath = async ( - candidatePath: string, - ): Promise<{ schema: Record; uiHints?: Record } | null> => { - try { - const resolved = await loadViaNativeImport(candidatePath); - if (resolved) { - return resolved; - } - } catch { - // Fall through to the compatibility loaders when the module needs custom - // plugin SDK aliasing or cannot be imported by the current Node loader. - } - - try { - // Prefer the source-aware Jiti path so generated config metadata stays - // stable before and after build output exists in the repo. - const imported = loadViaJiti(candidatePath); - const resolved = resolveConfigSchemaExport(imported); - if (resolved) { - return resolved; - } - } catch { - // Fall back to Bun below when the source-aware loader cannot resolve the - // module graph in the current environment. - } - - const bunLoaded = loadViaBun(candidatePath); - if (bunLoaded && isBuiltChannelConfigSchema(bunLoaded)) { - return bunLoaded; - } - return null; - }; - - return loadFromPath(modulePath); + const resolvedPath = path.resolve(modulePath); + const aliasMap = buildPluginLoaderAliasMap(resolvedPath, "", undefined, "src"); + // Jiti 2.7 passes Windows drive paths raw to import(); use its source loader there. + // Disabled caches keep generation source-current. + const jiti = createJiti(import.meta.url, { + ...buildPluginLoaderJitiOptions(aliasMap), + tryNative: process.platform !== "win32", + moduleCache: false, + fsCache: false, + }); + const imported = await jiti.import>(resolvedPath); + return resolveConfigSchemaExport(imported); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { diff --git a/src/config/load-channel-config-surface.test.ts b/src/config/load-channel-config-surface.test.ts index 0e5fade4c0dc..86087488ac9f 100644 --- a/src/config/load-channel-config-surface.test.ts +++ b/src/config/load-channel-config-surface.test.ts @@ -1,186 +1,86 @@ // Verifies channel config loading surfaces visible plugin settings. import fs from "node:fs"; import path from "node:path"; -import type { createJiti as createJitiType } from "jiti"; -import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { loadChannelConfigSurfaceModule } from "../../scripts/load-channel-config-surface.ts"; import { withTempDir } from "../test-helpers/temp-dir.js"; -const jitiFactoryOverrideKey = Symbol.for("openclaw.channelConfigSurfaceJitiFactoryOverride"); - -function stubChannelConfigSurfaceJitiFactory(createJiti: typeof createJitiType): void { - ( - globalThis as typeof globalThis & { - [jitiFactoryOverrideKey]?: typeof createJitiType; - } - )[jitiFactoryOverrideKey] = createJiti; -} - -afterEach(() => { - delete ( - globalThis as typeof globalThis & { - [jitiFactoryOverrideKey]?: typeof createJitiType; - } - )[jitiFactoryOverrideKey]; -}); - -async function importLoaderWithMissingBun() { - const spawnSync = vi.fn(() => ({ - error: Object.assign(new Error("bun not found"), { code: "ENOENT" }), - status: null, - stdout: "", - stderr: "", - })); - vi.doMock("node:child_process", () => ({ spawnSync })); - - try { - const imported = await importFreshModule< - typeof import("../../scripts/load-channel-config-surface.ts") - >(import.meta.url, "../../scripts/load-channel-config-surface.ts?scope=missing-bun"); - return { loadChannelConfigSurfaceModule: imported.loadChannelConfigSurfaceModule, spawnSync }; - } finally { - vi.doUnmock("node:child_process"); - } -} - -async function importLoaderWithFailingJitiAndWorkingBun() { - const spawnSync = vi.fn(() => ({ - error: undefined, - status: 0, - stdout: JSON.stringify({ - schema: { - type: "object", - properties: { - ok: { type: "number" }, - }, - }, - }), - stderr: "", - })); - const createJiti = vi.fn(() => () => { - throw new Error("jiti failed"); - }); - vi.doMock("node:child_process", () => ({ spawnSync })); - stubChannelConfigSurfaceJitiFactory(createJiti as unknown as typeof createJitiType); - - try { - const imported = await importFreshModule< - typeof import("../../scripts/load-channel-config-surface.ts") - >(import.meta.url, "../../scripts/load-channel-config-surface.ts?scope=failing-jiti"); - return { - loadChannelConfigSurfaceModule: imported.loadChannelConfigSurfaceModule, - spawnSync, - createJiti, - }; - } finally { - vi.doUnmock("node:child_process"); - } -} - -function expectedOkSchema(type: string) { - return { - schema: { - type: "object", - properties: { - ok: { type }, - }, - }, - }; -} - -function createDemoConfigSchemaModule(repoRoot: string, sourceLines?: string[]) { +function createDemoModule(repoRoot: string, filename: string, source: string): string { const packageRoot = path.join(repoRoot, "extensions", "demo"); - const modulePath = path.join(packageRoot, "src", "config-schema.js"); - - fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); + const modulePath = path.join(packageRoot, "src", filename); + fs.mkdirSync(path.dirname(modulePath), { recursive: true }); fs.writeFileSync( path.join(packageRoot, "package.json"), - JSON.stringify({ name: "@openclaw/demo", type: "module" }, null, 2), + JSON.stringify({ name: "@openclaw/demo", type: "module" }), "utf8", ); - fs.writeFileSync( - modulePath, - [ - ...(sourceLines ?? [ - "export const DemoChannelConfigSchema = {", - " schema: {", - " type: 'object',", - " properties: { ok: { type: 'string' } },", - " },", - "};", - ]), - "", - ].join("\n"), - "utf8", - ); - - return { packageRoot, modulePath }; + fs.writeFileSync(modulePath, source, "utf8"); + return modulePath; } describe("loadChannelConfigSurfaceModule", () => { - it("prefers the source-aware loader over bun when both succeed", async () => { + it("loads TypeScript through plugin SDK aliases", async () => { await withTempDir({ prefix: "openclaw-config-surface-" }, async (repoRoot) => { - const { modulePath } = createDemoConfigSchemaModule(repoRoot); + const modulePath = createDemoModule( + repoRoot, + "config-schema.ts", + ` + import { buildJsonChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; - const spawnSync = vi.fn(() => ({ - error: undefined, - status: 0, - stdout: JSON.stringify({ - schema: { - type: "object", - properties: { - ok: { type: "number" }, - }, - }, - }), - stderr: "", - })); - vi.doMock("node:child_process", () => ({ spawnSync })); + const label: string = "OK"; + export const DemoChannelConfigSchema = buildJsonChannelConfigSchema( + { type: "object", additionalProperties: false, properties: {} }, + { uiHints: { ok: { label } } }, + ); + `, + ); - try { - const imported = await importFreshModule< - typeof import("../../scripts/load-channel-config-surface.ts") - >(import.meta.url, "../../scripts/load-channel-config-surface.ts?scope=prefer-jiti"); - - const surface = await imported.loadChannelConfigSurfaceModule(modulePath, { repoRoot }); - expect(surface).toStrictEqual(expectedOkSchema("string")); - expect(spawnSync).not.toHaveBeenCalled(); - } finally { - vi.doUnmock("node:child_process"); - } + await expect(loadChannelConfigSurfaceModule(modulePath)).resolves.toMatchObject({ + schema: { type: "object", additionalProperties: false, properties: {} }, + uiHints: { ok: { label: "OK" } }, + }); }); }); - it("does not require bun when the source-aware loader succeeds", async () => { + it("wraps a raw config schema loaded natively", async () => { await withTempDir({ prefix: "openclaw-config-surface-" }, async (repoRoot) => { - const { modulePath } = createDemoConfigSchemaModule(repoRoot); + const modulePath = createDemoModule( + repoRoot, + "config-schema.mjs", + ` + export const DemoConfigSchema = { + toJSONSchema: () => ({ + type: "object", + properties: { count: { type: "number" } }, + }), + safeParse: (value) => ({ success: true, data: value }), + }; + `, + ); - const { loadChannelConfigSurfaceModule: loadWithMissingBun, spawnSync } = - await importLoaderWithMissingBun(); - - const surface = await loadWithMissingBun(modulePath, { repoRoot }); - expect(surface).toStrictEqual(expectedOkSchema("string")); - expect(spawnSync).not.toHaveBeenCalled(); + await expect(loadChannelConfigSurfaceModule(modulePath)).resolves.toMatchObject({ + schema: { type: "object", properties: { count: { type: "number" } } }, + }); }); }); - it("falls back to bun when the source-aware loader fails", async () => { + it("returns null without a config schema export", async () => { await withTempDir({ prefix: "openclaw-config-surface-" }, async (repoRoot) => { - const { modulePath } = createDemoConfigSchemaModule(repoRoot, ["export const = ;"]); + const modulePath = createDemoModule( + repoRoot, + "config-schema.mjs", + "export const unrelated = true;", + ); - const { loadChannelConfigSurfaceModule: loadWithFailingJiti, spawnSync } = - await importLoaderWithFailingJitiAndWorkingBun(); + await expect(loadChannelConfigSurfaceModule(modulePath)).resolves.toBeNull(); + }); + }); - const surface = await loadWithFailingJiti(modulePath, { repoRoot }); - expect(surface).toStrictEqual(expectedOkSchema("number")); + it("rejects invalid module source", async () => { + await withTempDir({ prefix: "openclaw-config-surface-" }, async (repoRoot) => { + const modulePath = createDemoModule(repoRoot, "config-schema.ts", "export const = ;"); - const spawnCalls = spawnSync.mock.calls as unknown as Array< - [string, string[], Record] - >; - const spawnCall = spawnCalls[0]; - expect(spawnCall?.[0]).toBe("bun"); - expect(Array.isArray(spawnCall?.[1])).toBe(true); - expect(typeof spawnCall?.[2]).toBe("object"); + await expect(loadChannelConfigSurfaceModule(modulePath)).rejects.toThrow(); }); }); });