mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
refactor(config): simplify channel schema loading (#106462)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>; uiHints?: Record<string, unknown> } {
|
||||
@@ -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<string, unknown>; uiHints?: Record<string, unknown> } | 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<string, unknown>;
|
||||
uiHints?: Record<string, unknown>;
|
||||
} | 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<string, unknown>;
|
||||
};
|
||||
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<string, unknown>; uiHints?: Record<string, unknown> } | 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<Record<string, unknown>>(resolvedPath);
|
||||
return resolveConfigSchemaExport(imported);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
|
||||
@@ -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<string, unknown>]
|
||||
>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user