fix: resolve bundled plugins from running CLI

This commit is contained in:
Peter Steinberger
2026-03-27 12:15:35 +00:00
parent ac2c2ac954
commit 324cddee4c
2 changed files with 98 additions and 18 deletions

View File

@@ -7,6 +7,7 @@ import { resolveBundledPluginsDir } from "./bundled-dir.js";
const tempDirs: string[] = [];
const originalBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const originalVitest = process.env.VITEST;
const originalArgv1 = process.argv[1];
function makeRepoRoot(prefix: string): string {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -26,6 +27,7 @@ afterEach(() => {
} else {
process.env.VITEST = originalVitest;
}
process.argv[1] = originalArgv1;
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -43,6 +45,7 @@ describe("resolveBundledPluginsDir", () => {
);
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
process.argv[1] = "/usr/bin/env";
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(repoRoot, "dist-runtime", "extensions")),
@@ -59,6 +62,7 @@ describe("resolveBundledPluginsDir", () => {
);
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
process.argv[1] = "/usr/bin/env";
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(repoRoot, "dist", "extensions")),
@@ -78,6 +82,7 @@ describe("resolveBundledPluginsDir", () => {
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
process.env.VITEST = "true";
process.argv[1] = "/usr/bin/env";
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(repoRoot, "extensions")),
@@ -99,9 +104,54 @@ describe("resolveBundledPluginsDir", () => {
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
delete process.env.VITEST;
process.argv[1] = "/usr/bin/env";
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(repoRoot, "extensions")),
);
});
it("prefers the running CLI package root over an unrelated cwd checkout", () => {
const installedRoot = makeRepoRoot("openclaw-bundled-dir-installed-");
fs.mkdirSync(path.join(installedRoot, "dist", "extensions"), { recursive: true });
fs.writeFileSync(
path.join(installedRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
const cwdRepoRoot = makeRepoRoot("openclaw-bundled-dir-cwd-");
fs.mkdirSync(path.join(cwdRepoRoot, "extensions"), { recursive: true });
fs.mkdirSync(path.join(cwdRepoRoot, "src"), { recursive: true });
fs.writeFileSync(path.join(cwdRepoRoot, ".git"), "gitdir: /tmp/fake.git\n", "utf8");
fs.writeFileSync(
path.join(cwdRepoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
vi.spyOn(process, "cwd").mockReturnValue(cwdRepoRoot);
process.argv[1] = path.join(installedRoot, "openclaw.mjs");
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(installedRoot, "dist", "extensions")),
);
});
it("falls back to the running installed package when the override path is stale", () => {
const installedRoot = makeRepoRoot("openclaw-bundled-dir-override-");
fs.mkdirSync(path.join(installedRoot, "dist", "extensions"), { recursive: true });
fs.writeFileSync(
path.join(installedRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
process.argv[1] = path.join(installedRoot, "openclaw.mjs");
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(installedRoot, "missing-extensions");
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(installedRoot, "dist", "extensions")),
);
});
});

View File

@@ -12,39 +12,69 @@ function isSourceCheckoutRoot(packageRoot: string): boolean {
);
}
function resolveBundledDirFromPackageRoot(
packageRoot: string,
preferSourceCheckout: boolean,
): string | undefined {
const sourceExtensionsDir = path.join(packageRoot, "extensions");
const builtExtensionsDir = path.join(packageRoot, "dist", "extensions");
if (
(preferSourceCheckout || isSourceCheckoutRoot(packageRoot)) &&
fs.existsSync(sourceExtensionsDir)
) {
return sourceExtensionsDir;
}
// Local source checkouts stage a runtime-complete bundled plugin tree under
// 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)) {
return runtimeExtensionsDir;
}
if (fs.existsSync(builtExtensionsDir)) {
return builtExtensionsDir;
}
return undefined;
}
export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
const override = env.OPENCLAW_BUNDLED_PLUGINS_DIR?.trim();
if (override) {
return resolveUserPath(override, env);
const resolvedOverride = resolveUserPath(override, env);
if (fs.existsSync(resolvedOverride)) {
return resolvedOverride;
}
// Installed CLIs can inherit stale bundled-dir overrides from older shells
// or debug sessions. Prefer the package that owns argv[1] over a broken
// override so bundled providers keep working in packaged installs.
try {
const argvPackageRoot = resolveOpenClawPackageRootSync({ argv1: process.argv[1] });
if (argvPackageRoot && !isSourceCheckoutRoot(argvPackageRoot)) {
const argvFallback = resolveBundledDirFromPackageRoot(argvPackageRoot, false);
if (argvFallback) {
return argvFallback;
}
}
} catch {
// ignore
}
return resolvedOverride;
}
const preferSourceCheckout = Boolean(env.VITEST);
try {
const packageRoots = [
resolveOpenClawPackageRootSync({ argv1: process.argv[1] }),
resolveOpenClawPackageRootSync({ cwd: process.cwd() }),
resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url }),
].filter(
(entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index,
);
for (const packageRoot of packageRoots) {
const sourceExtensionsDir = path.join(packageRoot, "extensions");
const builtExtensionsDir = path.join(packageRoot, "dist", "extensions");
if (
(preferSourceCheckout || isSourceCheckoutRoot(packageRoot)) &&
fs.existsSync(sourceExtensionsDir)
) {
return sourceExtensionsDir;
}
// Local source checkouts stage a runtime-complete bundled plugin tree under
// 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)) {
return runtimeExtensionsDir;
}
if (fs.existsSync(builtExtensionsDir)) {
return builtExtensionsDir;
const bundledDir = resolveBundledDirFromPackageRoot(packageRoot, preferSourceCheckout);
if (bundledDir) {
return bundledDir;
}
}
} catch {