From 739ce820155ef42569d8936dca6a945ba20af866 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 6 Apr 2026 18:51:33 +0100 Subject: [PATCH] fix(plugins): prefer usable bundled plugin trees --- src/plugins/bundled-dir.test.ts | 43 +++++++++++++++++++++++++++++++++ src/plugins/bundled-dir.ts | 33 ++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index 40b8a36db7c..951de8873b3 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -46,6 +46,21 @@ function createOpenClawRoot(params: { return repoRoot; } +function seedBundledPluginTree(rootDir: string, relativeDir: string, pluginId = "discord") { + const pluginDir = path.join(rootDir, relativeDir, pluginId); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + `${JSON.stringify({ name: `@openclaw/${pluginId}` }, null, 2)}\n`, + "utf8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + `${JSON.stringify({ id: pluginId }, null, 2)}\n`, + "utf8", + ); +} + function expectResolvedBundledDir(params: { cwd: string; expectedDir: string; @@ -205,6 +220,12 @@ describe("resolveBundledPluginsDir", () => { ], ] as const)("%s", (_name, layout, expectation) => { const repoRoot = createOpenClawRoot(layout); + if (expectation.expectedRelativeDir === path.join("dist-runtime", "extensions")) { + seedBundledPluginTree(repoRoot, path.join("dist", "extensions")); + seedBundledPluginTree(repoRoot, path.join("dist-runtime", "extensions")); + } else if (expectation.expectedRelativeDir === path.join("dist", "extensions")) { + seedBundledPluginTree(repoRoot, path.join("dist", "extensions")); + } expectResolvedBundledDirFromRoot({ repoRoot, expectedRelativeDir: expectation.expectedRelativeDir, @@ -212,6 +233,26 @@ describe("resolveBundledPluginsDir", () => { }); }); + it("falls back to source extensions when dist trees exist but do not contain real plugin manifests", () => { + const repoRoot = createOpenClawRoot({ + prefix: "openclaw-bundled-dir-incomplete-built-", + hasExtensions: true, + hasSrc: true, + hasDistRuntimeExtensions: true, + hasDistExtensions: true, + hasGitCheckout: true, + }); + fs.mkdirSync(path.join(repoRoot, "dist", "extensions", "discord"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "dist-runtime", "extensions", "discord"), { + recursive: true, + }); + + expectResolvedBundledDirFromRoot({ + repoRoot, + expectedRelativeDir: "extensions", + }); + }); + it("returns a stable empty bundled plugin directory when bundled plugins are disabled", () => { const repoRoot = createOpenClawRoot({ prefix: "openclaw-bundled-dir-disabled-", @@ -239,6 +280,7 @@ describe("resolveBundledPluginsDir", () => { prefix: "openclaw-bundled-dir-installed-", hasDistExtensions: true, }); + seedBundledPluginTree(installedRoot, path.join("dist", "extensions")); const cwdRepoRoot = createOpenClawRoot({ prefix: "openclaw-bundled-dir-cwd-", hasExtensions: true, @@ -259,6 +301,7 @@ describe("resolveBundledPluginsDir", () => { prefix: "openclaw-bundled-dir-override-", hasDistExtensions: true, }); + seedBundledPluginTree(installedRoot, path.join("dist", "extensions")); return { installedRoot, argv1: path.join(installedRoot, "openclaw.mjs"), diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index a58451fb8d9..de49e67a046 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -25,12 +25,33 @@ function isSourceCheckoutRoot(packageRoot: string): boolean { ); } +function hasUsableBundledPluginTree(pluginsDir: string): boolean { + if (!fs.existsSync(pluginsDir)) { + return false; + } + try { + return fs.readdirSync(pluginsDir, { withFileTypes: true }).some((entry) => { + if (!entry.isDirectory()) { + return false; + } + const pluginDir = path.join(pluginsDir, entry.name); + return ( + fs.existsSync(path.join(pluginDir, "package.json")) || + fs.existsSync(path.join(pluginDir, "openclaw.plugin.json")) + ); + }); + } catch { + return false; + } +} + function resolveBundledDirFromPackageRoot( packageRoot: string, preferSourceCheckout: boolean, ): string | undefined { const sourceExtensionsDir = path.join(packageRoot, "extensions"); const builtExtensionsDir = path.join(packageRoot, "dist", "extensions"); + const sourceCheckout = isSourceCheckoutRoot(packageRoot); if (preferSourceCheckout && fs.existsSync(sourceExtensionsDir)) { return sourceExtensionsDir; } @@ -38,13 +59,19 @@ function resolveBundledDirFromPackageRoot( // dist-runtime/. Prefer that over source extensions only when the paired // dist/ tree exists; otherwise wrappers can drift ahead of the last build. const runtimeExtensionsDir = path.join(packageRoot, "dist-runtime", "extensions"); - if (fs.existsSync(runtimeExtensionsDir) && fs.existsSync(builtExtensionsDir)) { + const hasUsableRuntimeTree = sourceCheckout + ? hasUsableBundledPluginTree(runtimeExtensionsDir) + : fs.existsSync(runtimeExtensionsDir); + const hasUsableBuiltTree = sourceCheckout + ? hasUsableBundledPluginTree(builtExtensionsDir) + : fs.existsSync(builtExtensionsDir); + if (hasUsableRuntimeTree && hasUsableBuiltTree) { return runtimeExtensionsDir; } - if (fs.existsSync(builtExtensionsDir)) { + if (hasUsableBuiltTree) { return builtExtensionsDir; } - if (isSourceCheckoutRoot(packageRoot) && fs.existsSync(sourceExtensionsDir)) { + if (sourceCheckout && fs.existsSync(sourceExtensionsDir)) { return sourceExtensionsDir; } return undefined;