fix(plugins): build package-local npm runtimes

This commit is contained in:
Vincent Koc
2026-05-02 22:36:18 -07:00
parent ac7e7f0512
commit 11a5b30f3e
11 changed files with 621 additions and 91 deletions

View File

@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
resolveAugmentedPluginNpmPackageJson,
resolveAugmentedPluginNpmManifest,
withAugmentedPluginNpmManifestForPackage,
} from "../scripts/lib/plugin-npm-package-manifest.mjs";
@@ -48,6 +49,28 @@ function writeFileText(filePath: string, text: string): void {
writeFileSync(filePath, text, "utf8");
}
function writePublishablePluginPackage(repoDir: string): string {
const packageDir = join(repoDir, "extensions", "diffs");
mkdirSync(packageDir, { recursive: true });
writeJsonFile(join(packageDir, "package.json"), {
name: "@openclaw/diffs",
version: "2026.5.3",
type: "module",
openclaw: {
extensions: ["./index.ts"],
setupEntry: "./setup-entry.ts",
release: {
publishToNpm: true,
},
},
});
writeJsonFile(join(packageDir, "openclaw.plugin.json"), { id: "diffs" });
writeFileText(join(packageDir, "README.md"), "# Diffs\n");
writeFileText(join(packageDir, "SKILL.md"), "# Diffs Skill\n");
writeFileText(join(packageDir, "skills", "diffs", "SKILL.md"), "# Diffs Skill\n");
return packageDir;
}
describe("plugin npm package manifest staging", () => {
it("overlays generated channel configs while packing and restores source manifest", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-manifest-");
@@ -90,4 +113,49 @@ describe("plugin npm package manifest staging", () => {
});
expect(readFileSync(join(packageDir, "openclaw.plugin.json"), "utf8")).toBe(originalText);
});
it("overlays package-local runtime metadata while packing and restores source package json", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-runtime-");
const packageDir = writePublishablePluginPackage(repoDir);
writeFileText(join(packageDir, "dist", "index.js"), "export {};\n");
writeFileText(join(packageDir, "dist", "setup-entry.js"), "export {};\n");
const resolved = resolveAugmentedPluginNpmPackageJson({
repoRoot: repoDir,
packageDir,
});
expect(resolved.changed).toBe(true);
expect(resolved.packageJson).toMatchObject({
files: ["dist/**", "openclaw.plugin.json", "README.md", "SKILL.md", "skills/**"],
openclaw: {
runtimeExtensions: ["./dist/index.js"],
runtimeSetupEntry: "./dist/setup-entry.js",
},
});
const originalText = readFileSync(join(packageDir, "package.json"), "utf8");
withAugmentedPluginNpmManifestForPackage({ repoRoot: repoDir, packageDir }, () => {
const stagedPackageJson = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
expect(stagedPackageJson.openclaw.extensions).toEqual(["./index.ts"]);
expect(stagedPackageJson.openclaw.runtimeExtensions).toEqual(["./dist/index.js"]);
expect(stagedPackageJson.openclaw.runtimeSetupEntry).toBe("./dist/setup-entry.js");
expect(stagedPackageJson.files).toContain("dist/**");
expect(stagedPackageJson.files).toContain("skills/**");
});
expect(readFileSync(join(packageDir, "package.json"), "utf8")).toBe(originalText);
});
it("refuses to pack publishable plugins before package-local runtime files exist", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-runtime-missing-");
const packageDir = writePublishablePluginPackage(repoDir);
expect(() =>
resolveAugmentedPluginNpmPackageJson({
repoRoot: repoDir,
packageDir,
}),
).toThrow(
"package-local plugin runtime is missing for diffs: ./dist/index.js, ./dist/setup-entry.js",
);
});
});

View File

@@ -0,0 +1,79 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolvePluginNpmRuntimeBuildPlan } from "../scripts/lib/plugin-npm-runtime-build.mjs";
const repoRoot = path.resolve(import.meta.dirname, "..");
function readJsonFile(filePath: string): Record<string, unknown> {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as Record<string, unknown>;
}
function isPublishablePluginPackage(packageJson: Record<string, unknown>): boolean {
const openclaw = packageJson.openclaw as { release?: { publishToNpm?: unknown } } | undefined;
return openclaw?.release?.publishToNpm === true;
}
function listPublishablePluginPackageDirs(): string[] {
const extensionsRoot = path.join(repoRoot, "extensions");
return fs
.readdirSync(extensionsRoot, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => path.join(extensionsRoot, dirent.name))
.filter((packageDir) => {
const packageJsonPath = path.join(packageDir, "package.json");
return (
fs.existsSync(packageJsonPath) && isPublishablePluginPackage(readJsonFile(packageJsonPath))
);
})
.toSorted((left, right) => left.localeCompare(right));
}
describe("plugin npm runtime build planning", () => {
it("plans package-local runtime entries for every publishable plugin package", () => {
const packageDirs = listPublishablePluginPackageDirs();
expect(packageDirs.length).toBeGreaterThan(0);
const plans = packageDirs.map((packageDir) =>
resolvePluginNpmRuntimeBuildPlan({
repoRoot,
packageDir,
}),
);
expect(plans.filter(Boolean).map((plan) => plan?.pluginDir)).toEqual(
packageDirs.map((packageDir) => path.basename(packageDir)),
);
for (const plan of plans) {
expect(plan?.outDir).toBe(path.join(plan?.packageDir ?? "", "dist"));
expect(plan?.runtimeExtensions.every((entry) => entry.startsWith("./dist/"))).toBe(true);
}
});
it("includes top-level public runtime surfaces and root-build-excluded plugins", () => {
const qqbotPlan = resolvePluginNpmRuntimeBuildPlan({
repoRoot,
packageDir: path.join(repoRoot, "extensions", "qqbot"),
});
expect(qqbotPlan?.entry).toEqual(
expect.objectContaining({
index: path.join(repoRoot, "extensions", "qqbot", "index.ts"),
"runtime-api": path.join(repoRoot, "extensions", "qqbot", "runtime-api.ts"),
"setup-entry": path.join(repoRoot, "extensions", "qqbot", "setup-entry.ts"),
}),
);
expect(qqbotPlan?.runtimeExtensions).toEqual(["./dist/index.js"]);
expect(qqbotPlan?.runtimeSetupEntry).toBe("./dist/setup-entry.js");
const diffsPlan = resolvePluginNpmRuntimeBuildPlan({
repoRoot,
packageDir: path.join(repoRoot, "extensions", "diffs"),
});
expect(diffsPlan?.entry).toEqual(
expect.objectContaining({
api: path.join(repoRoot, "extensions", "diffs", "api.ts"),
index: path.join(repoRoot, "extensions", "diffs", "index.ts"),
"runtime-api": path.join(repoRoot, "extensions", "diffs", "runtime-api.ts"),
}),
);
});
});