fix(plugins): link host declared as direct dependency (#117738)

This commit is contained in:
Peter Steinberger
2026-08-01 18:58:33 -07:00
committed by GitHub
parent 78193deff3
commit b2da7aa106
2 changed files with 79 additions and 51 deletions
+1 -1
View File
@@ -196,7 +196,7 @@ export async function validatePackagePluginInstallSource(params: {
? { setup: ocManifestResult.manifest.setup }
: {}),
hasRuntimeDependencies: hasPackageRuntimeDependencies(manifest),
peerDependencies: manifest.peerDependencies ?? {},
peerDependencies: { ...manifest.dependencies, ...manifest.peerDependencies },
},
};
}
+78 -50
View File
@@ -3820,6 +3820,26 @@ describe("installPluginFromDir", () => {
describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => {
const resolveRootMock = vi.mocked(resolveOpenClawPackageRootSync);
const hostDependencyDeclarations: Array<{
declaration: string;
peerDependencies: Record<string, string>;
dependencies?: Record<string, string>;
}> = [
{
declaration: "peerDependencies",
peerDependencies: { openclaw: "*" },
},
{
declaration: "dependencies",
peerDependencies: {},
dependencies: { openclaw: "*" },
},
{
declaration: "dependencies alongside an unrelated peer dependency",
peerDependencies: { "unrelated-host": "^1.0.0" },
dependencies: { openclaw: "*" },
},
];
function writePluginWithPeerDeps(
pluginDir: string,
@@ -3841,27 +3861,29 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => {
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n", "utf-8");
}
it("creates a node_modules/openclaw symlink when peerDependencies declares openclaw", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
const fakeHostRoot = suiteTempRootTracker.makeTempDir();
const run = vi.mocked(runCommandWithTimeout);
resolveRootMock.mockReturnValue(fakeHostRoot);
it.each(hostDependencyDeclarations)(
"creates a host-targeted node_modules/openclaw symlink for $declaration",
async ({ peerDependencies, dependencies }) => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
const fakeHostRoot = suiteTempRootTracker.makeTempDir();
const run = vi.mocked(runCommandWithTimeout);
resolveRootMock.mockReturnValue(fakeHostRoot);
writePluginWithPeerDeps(pluginDir, { openclaw: "*" });
writePluginWithPeerDeps(pluginDir, peerDependencies, dependencies);
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
const symlinkPath = path.join(result.targetDir, "node_modules", "openclaw");
const stat = fs.lstatSync(symlinkPath);
expect(stat.isSymbolicLink()).toBe(true);
expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot));
expect(run).not.toHaveBeenCalled();
});
const symlinkPath = path.join(result.targetDir, "node_modules", "openclaw");
expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot));
expect(run).not.toHaveBeenCalled();
},
);
it("keeps the openclaw peer symlink when a local plugin already has dependencies", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
@@ -3890,33 +3912,36 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => {
expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled();
});
it("replaces a copied local openclaw package with the host peer symlink", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
const fakeHostRoot = suiteTempRootTracker.makeTempDir();
resolveRootMock.mockReturnValue(fakeHostRoot);
it.each(hostDependencyDeclarations)(
"replaces a copied local openclaw package with the host symlink for $declaration",
async ({ peerDependencies, dependencies }) => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
const fakeHostRoot = suiteTempRootTracker.makeTempDir();
resolveRootMock.mockReturnValue(fakeHostRoot);
writePluginWithPeerDeps(pluginDir, { openclaw: "*" });
fs.mkdirSync(path.join(pluginDir, "node_modules", "openclaw"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "node_modules", "openclaw", "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.5.31" }),
"utf-8",
);
writePluginWithPeerDeps(pluginDir, peerDependencies, dependencies);
fs.mkdirSync(path.join(pluginDir, "node_modules", "openclaw"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "node_modules", "openclaw", "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.5.31" }),
"utf-8",
);
const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(true);
expect(warnings).toHaveLength(0);
if (!result.ok) {
return;
}
expect(result.ok).toBe(true);
expect(warnings).toHaveLength(0);
if (!result.ok) {
return;
}
const symlinkPath = path.join(result.targetDir, "node_modules", "openclaw");
expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot));
});
const symlinkPath = path.join(result.targetDir, "node_modules", "openclaw");
expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot));
},
);
it("does not create a symlink when peerDependencies is empty", async () => {
it("does not create a symlink when neither dependency map declares openclaw", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
resolveRootMock.mockReturnValue(suiteTempRootTracker.makeTempDir());
@@ -3961,19 +3986,22 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => {
expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true);
});
it("rejects when resolveOpenClawPackageRootSync returns null", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
resolveRootMock.mockReturnValue(null);
it.each(hostDependencyDeclarations)(
"rejects $declaration when the host package root cannot be resolved",
async ({ peerDependencies, dependencies }) => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
resolveRootMock.mockReturnValue(null);
writePluginWithPeerDeps(pluginDir, { openclaw: "*" });
writePluginWithPeerDeps(pluginDir, peerDependencies, dependencies);
const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("plugin-local node_modules/openclaw link");
}
expectWarningIncludes(warnings, "Could not locate openclaw package root");
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("plugin-local node_modules/openclaw link");
}
expectWarningIncludes(warnings, "Could not locate openclaw package root");
},
);
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */