diff --git a/scripts/load-channel-config-surface.ts b/scripts/load-channel-config-surface.ts index d658495371f..855643fc2ad 100644 --- a/scripts/load-channel-config-surface.ts +++ b/scripts/load-channel-config-surface.ts @@ -77,6 +77,13 @@ function shouldRetryViaIsolatedCopy(error: unknown): boolean { return code === "ERR_MODULE_NOT_FOUND" && message.includes(`${path.sep}node_modules${path.sep}`); } +function isMissingExecutableError(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + return "code" in error && error.code === "ENOENT"; +} + const SOURCE_FILE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]; function resolveImportCandidates(basePath: string): string[] { @@ -232,6 +239,12 @@ export async function loadChannelConfigSurfaceModule( 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}`); } diff --git a/src/config/load-channel-config-surface.test.ts b/src/config/load-channel-config-surface.test.ts index f001304fbd0..20ea8e95ed8 100644 --- a/src/config/load-channel-config-surface.test.ts +++ b/src/config/load-channel-config-surface.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { loadChannelConfigSurfaceModule } from "../../scripts/load-channel-config-surface.ts"; const tempDirs: string[] = []; @@ -12,6 +12,25 @@ function makeTempRoot(prefix: string): string { return root; } +async function importLoaderWithMissingBun() { + vi.resetModules(); + 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 import("../../scripts/load-channel-config-surface.ts"); + return { loadChannelConfigSurfaceModule: imported.loadChannelConfigSurfaceModule, spawnSync }; + } finally { + vi.doUnmock("node:child_process"); + vi.resetModules(); + } +} + afterEach(() => { for (const dir of tempDirs.splice(0, tempDirs.length)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -19,6 +38,45 @@ afterEach(() => { }); describe("loadChannelConfigSurfaceModule", () => { + it("falls back to Jiti when bun is unavailable", async () => { + const repoRoot = makeTempRoot("openclaw-config-surface-"); + const packageRoot = path.join(repoRoot, "extensions", "demo"); + const modulePath = path.join(packageRoot, "src", "config-schema.js"); + + fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "@openclaw/demo", type: "module" }, null, 2), + "utf8", + ); + fs.writeFileSync( + modulePath, + [ + "export const DemoChannelConfigSchema = {", + " schema: {", + " type: 'object',", + " properties: { ok: { type: 'string' } },", + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + + const { loadChannelConfigSurfaceModule: loadWithMissingBun, spawnSync } = + await importLoaderWithMissingBun(); + + await expect(loadWithMissingBun(modulePath, { repoRoot })).resolves.toMatchObject({ + schema: { + type: "object", + properties: { + ok: { type: "string" }, + }, + }, + }); + expect(spawnSync).toHaveBeenCalledWith("bun", expect.any(Array), expect.any(Object)); + }); + it("retries from an isolated package copy when extension-local node_modules is broken", async () => { const repoRoot = makeTempRoot("openclaw-config-surface-"); const packageRoot = path.join(repoRoot, "extensions", "demo");