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

@@ -31,7 +31,7 @@ function isManifestlessBundledRuntimeSupportPackage(params) {
return params.topLevelPublicSurfaceEntries.length > 0;
}
function collectPluginSourceEntries(packageJson) {
export function collectPluginSourceEntries(packageJson) {
let packageEntries = Array.isArray(packageJson?.openclaw?.extensions)
? packageJson.openclaw.extensions.filter(
(entry) => typeof entry === "string" && entry.trim().length > 0,
@@ -48,7 +48,7 @@ function collectPluginSourceEntries(packageJson) {
return packageEntries.length > 0 ? packageEntries : ["./index.ts"];
}
function collectTopLevelPublicSurfaceEntries(pluginDir) {
export function collectTopLevelPublicSurfaceEntries(pluginDir) {
if (!fs.existsSync(pluginDir)) {
return [];
}

View File

@@ -1,44 +1,24 @@
[
"dist/extensions/acpx/runtime-api.js",
"dist/extensions/bluebubbles/runtime-api.js",
"dist/extensions/browser/runtime-api.js",
"dist/extensions/copilot-proxy/runtime-api.js",
"dist/extensions/diffs/runtime-api.js",
"dist/extensions/discord/runtime-api.js",
"dist/extensions/discord/runtime-setter-api.js",
"dist/extensions/feishu/runtime-api.js",
"dist/extensions/google/runtime-api.js",
"dist/extensions/googlechat/runtime-api.js",
"dist/extensions/imessage/runtime-api.js",
"dist/extensions/irc/runtime-api.js",
"dist/extensions/line/runtime-api.js",
"dist/extensions/lmstudio/runtime-api.js",
"dist/extensions/lobster/runtime-api.js",
"dist/extensions/matrix/helper-api.js",
"dist/extensions/matrix/runtime-api.js",
"dist/extensions/matrix/runtime-setter-api.js",
"dist/extensions/matrix/thread-bindings-runtime.js",
"dist/extensions/mattermost/runtime-api.js",
"dist/extensions/memory-core/runtime-api.js",
"dist/extensions/msteams/runtime-api.js",
"dist/extensions/nextcloud-talk/runtime-api.js",
"dist/extensions/nostr/runtime-api.js",
"dist/extensions/ollama/runtime-api.js",
"dist/extensions/open-prose/runtime-api.js",
"dist/extensions/qqbot/runtime-api.js",
"dist/extensions/signal/runtime-api.js",
"dist/extensions/slack/runtime-api.js",
"dist/extensions/slack/runtime-setter-api.js",
"dist/extensions/telegram/runtime-api.js",
"dist/extensions/telegram/runtime-setter-api.js",
"dist/extensions/tlon/runtime-api.js",
"dist/extensions/tokenjuice/runtime-api.js",
"dist/extensions/twitch/runtime-api.js",
"dist/extensions/voice-call/runtime-api.js",
"dist/extensions/webhooks/runtime-api.js",
"dist/extensions/whatsapp/light-runtime-api.js",
"dist/extensions/whatsapp/runtime-api.js",
"dist/extensions/zai/runtime-api.js",
"dist/extensions/zalo/runtime-api.js",
"dist/extensions/zalouser/runtime-api.js"
"dist/extensions/zai/runtime-api.js"
]

View File

@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import JSON5 from "json5";
import { resolvePluginNpmRuntimeBuildPlan } from "./plugin-npm-runtime-build.mjs";
const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA_PATH =
"src/config/bundled-channel-config-metadata.generated.ts";
@@ -19,6 +20,103 @@ function resolvePackageDir(repoRoot, packageDir) {
return path.isAbsolute(packageDir) ? packageDir : path.resolve(repoRoot, packageDir);
}
function resolvePackageJsonPath(packageDir) {
return path.join(packageDir, "package.json");
}
function packageRelativePathExists(packageDir, relativePath) {
return fs.existsSync(path.join(packageDir, relativePath));
}
function mergePackageFiles(packageDir, files) {
const merged = new Set(
Array.isArray(files) ? files.filter((entry) => typeof entry === "string") : [],
);
merged.add("dist/**");
if (packageRelativePathExists(packageDir, "openclaw.plugin.json")) {
merged.add("openclaw.plugin.json");
}
if (packageRelativePathExists(packageDir, "README.md")) {
merged.add("README.md");
}
if (packageRelativePathExists(packageDir, "SKILL.md")) {
merged.add("SKILL.md");
}
if (packageRelativePathExists(packageDir, "skills")) {
merged.add("skills/**");
}
return [...merged];
}
function listRuntimeBuildOutputs(plan) {
return Object.keys(plan.entry)
.map((entryKey) => `./dist/${entryKey}.js`)
.toSorted((left, right) => left.localeCompare(right));
}
function assertPluginNpmRuntimeBuildExists(plan) {
const missing = listRuntimeBuildOutputs(plan).filter(
(runtimePath) => !packageRelativePathExists(plan.packageDir, runtimePath.replace(/^\.\//u, "")),
);
if (missing.length > 0) {
throw new Error(
[
`package-local plugin runtime is missing for ${plan.pluginDir}: ${missing.join(", ")}`,
`Run node scripts/lib/plugin-npm-runtime-build.mjs ${path.relative(plan.repoRoot, plan.packageDir) || plan.packageDir} before publishing ${plan.packageJson.name ?? plan.pluginDir}.`,
].join("\n"),
);
}
}
export function resolveAugmentedPluginNpmPackageJson(params) {
const repoRoot = path.resolve(params.repoRoot ?? ".");
const packageDir = resolvePackageDir(repoRoot, params.packageDir);
const packageJsonPath = resolvePackageJsonPath(packageDir);
if (!fs.existsSync(packageJsonPath)) {
return {
packageJsonPath,
packageDir,
repoRoot,
changed: false,
packageJson: undefined,
reason: "missing-package-json",
};
}
const plan = resolvePluginNpmRuntimeBuildPlan({ repoRoot, packageDir });
if (!plan) {
return {
packageJsonPath,
packageDir,
repoRoot,
changed: false,
packageJson: undefined,
reason: "no-runtime-build",
};
}
assertPluginNpmRuntimeBuildExists(plan);
const packageJson = {
...plan.packageJson,
files: mergePackageFiles(packageDir, plan.packageJson.files),
openclaw: {
...plan.packageJson.openclaw,
runtimeExtensions: plan.runtimeExtensions,
...(plan.runtimeSetupEntry ? { runtimeSetupEntry: plan.runtimeSetupEntry } : {}),
},
};
const changed = JSON.stringify(packageJson) !== JSON.stringify(plan.packageJson);
return {
packageJsonPath,
packageDir,
repoRoot,
changed,
packageJson,
pluginDir: plan.pluginDir,
reason: changed ? "package-local-runtime" : "unchanged",
};
}
function readGeneratedBundledChannelConfigs(repoRoot) {
const metadataPath = path.join(repoRoot, GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA_PATH);
if (!fs.existsSync(metadataPath)) {
@@ -133,34 +231,63 @@ export function resolveAugmentedPluginNpmManifest(params) {
export function withAugmentedPluginNpmManifestForPackage(params, callback) {
const repoRoot = path.resolve(params.repoRoot ?? ".");
const packageDir = resolvePackageDir(repoRoot, params.packageDir);
const resolved = resolveAugmentedPluginNpmManifest({
const resolvedManifest = resolveAugmentedPluginNpmManifest({
repoRoot,
packageDir,
});
const resolvedPackageJson = resolveAugmentedPluginNpmPackageJson({
repoRoot,
packageDir,
});
if (!resolved.changed || !resolved.manifest) {
if (
(!resolvedManifest.changed || !resolvedManifest.manifest) &&
(!resolvedPackageJson.changed || !resolvedPackageJson.packageJson)
) {
return callback({
...resolved,
...resolvedManifest,
packageDir,
repoRoot,
applied: false,
packageJsonApplied: false,
});
}
const originalManifest = fs.readFileSync(resolved.manifestPath, "utf8");
console.error(
`[plugin-npm-publish] overlaying generated channel config metadata for ${resolved.pluginId}`,
);
writeJsonFile(resolved.manifestPath, resolved.manifest);
const originalManifest =
resolvedManifest.changed && resolvedManifest.manifest
? fs.readFileSync(resolvedManifest.manifestPath, "utf8")
: undefined;
const originalPackageJson =
resolvedPackageJson.changed && resolvedPackageJson.packageJson
? fs.readFileSync(resolvedPackageJson.packageJsonPath, "utf8")
: undefined;
if (resolvedManifest.changed && resolvedManifest.manifest) {
console.error(
`[plugin-npm-publish] overlaying generated channel config metadata for ${resolvedManifest.pluginId}`,
);
writeJsonFile(resolvedManifest.manifestPath, resolvedManifest.manifest);
}
if (resolvedPackageJson.changed && resolvedPackageJson.packageJson) {
console.error(
`[plugin-npm-publish] overlaying package-local runtime metadata for ${resolvedPackageJson.pluginDir}`,
);
writeJsonFile(resolvedPackageJson.packageJsonPath, resolvedPackageJson.packageJson);
}
try {
return callback({
...resolved,
...resolvedManifest,
packageDir,
repoRoot,
applied: true,
applied: resolvedManifest.changed && Boolean(resolvedManifest.manifest),
packageJsonApplied: resolvedPackageJson.changed && Boolean(resolvedPackageJson.packageJson),
});
} finally {
fs.writeFileSync(resolved.manifestPath, originalManifest, "utf8");
if (originalManifest !== undefined) {
fs.writeFileSync(resolvedManifest.manifestPath, originalManifest, "utf8");
}
if (originalPackageJson !== undefined) {
fs.writeFileSync(resolvedPackageJson.packageJsonPath, originalPackageJson, "utf8");
}
}
}

View File

@@ -0,0 +1,172 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { build } from "tsdown";
import {
collectPluginSourceEntries,
collectTopLevelPublicSurfaceEntries,
} from "./bundled-plugin-build-entries.mjs";
import { copyStaticExtensionAssetsForPackage } from "./static-extension-assets.mjs";
const env = {
NODE_ENV: "production",
};
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function normalizePackageEntry(value) {
return typeof value === "string" ? value.trim().replaceAll("\\", "/") : "";
}
function isTypeScriptEntry(entry) {
return /\.(?:c|m)?ts$/u.test(entry);
}
function toPackageRuntimeEntry(entry) {
const normalized = normalizePackageEntry(entry).replace(/^\.\//u, "");
return `./dist/${normalized.replace(/\.[^.]+$/u, ".js")}`;
}
function collectExternalDependencyNames(packageJson) {
return new Set(
[
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.peerDependencies ?? {}),
...Object.keys(packageJson.optionalDependencies ?? {}),
].filter(Boolean),
);
}
function createNeverBundleDependencyMatcher(packageJson) {
const externalDependencies = collectExternalDependencyNames(packageJson);
return (id) => {
if (id === "openclaw" || id.startsWith("openclaw/")) {
return true;
}
for (const dependency of externalDependencies) {
if (id === dependency || id.startsWith(`${dependency}/`)) {
return true;
}
}
return false;
};
}
function packageEntryKey(entry) {
return normalizePackageEntry(entry)
.replace(/^\.\//u, "")
.replace(/\.[^.]+$/u, "");
}
function resolvePackageDir(repoRoot, packageDir) {
return path.isAbsolute(packageDir) ? packageDir : path.resolve(repoRoot, packageDir);
}
export function resolvePluginNpmRuntimeBuildPlan(params) {
const repoRoot = path.resolve(params.repoRoot ?? ".");
const packageDir = resolvePackageDir(repoRoot, params.packageDir);
const packageJsonPath = path.join(packageDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return null;
}
const packageJson = readJsonFile(packageJsonPath);
if (packageJson.openclaw?.release?.publishToNpm !== true) {
return null;
}
const packageEntries = collectPluginSourceEntries(packageJson).map(normalizePackageEntry);
const requiresRuntimeBuild = packageEntries.some(isTypeScriptEntry);
if (!requiresRuntimeBuild) {
return null;
}
const pluginDir = path.basename(packageDir);
const sourceEntries = [
...new Set([
...packageEntries,
...collectTopLevelPublicSurfaceEntries(packageDir).map(normalizePackageEntry),
]),
].filter(Boolean);
const entry = Object.fromEntries(
sourceEntries.map((sourceEntry) => [
packageEntryKey(sourceEntry),
path.join(packageDir, sourceEntry.replace(/^\.\//u, "")),
]),
);
return {
repoRoot,
packageDir,
pluginDir,
packageJson,
sourceEntries,
entry,
outDir: path.join(packageDir, "dist"),
runtimeExtensions: (Array.isArray(packageJson.openclaw?.extensions)
? packageJson.openclaw.extensions
: []
)
.map(normalizePackageEntry)
.filter(Boolean)
.map(toPackageRuntimeEntry),
runtimeSetupEntry: normalizePackageEntry(packageJson.openclaw?.setupEntry)
? toPackageRuntimeEntry(packageJson.openclaw.setupEntry)
: undefined,
};
}
export async function buildPluginNpmRuntime(params) {
const plan = resolvePluginNpmRuntimeBuildPlan(params);
if (!plan) {
return null;
}
fs.rmSync(plan.outDir, { recursive: true, force: true });
await build({
clean: false,
config: false,
dts: false,
deps: {
neverBundle: createNeverBundleDependencyMatcher(plan.packageJson),
},
entry: plan.entry,
env,
fixedExtension: false,
logLevel: params.logLevel ?? "info",
outDir: plan.outDir,
platform: "node",
});
const copiedStaticAssets = copyStaticExtensionAssetsForPackage({
rootDir: plan.repoRoot,
pluginDir: plan.pluginDir,
});
return {
...plan,
copiedStaticAssets,
};
}
function parseArgs(argv) {
const packageDir = argv[0];
if (!packageDir) {
throw new Error("usage: node scripts/lib/plugin-npm-runtime-build.mjs <package-dir>");
}
return { packageDir };
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
const { packageDir } = parseArgs(process.argv.slice(2));
const result = await buildPluginNpmRuntime({ packageDir });
if (result) {
console.error(
`[plugin-npm-runtime-build] built ${result.pluginDir} runtime (${result.sourceEntries.length} entries)`,
);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}

View File

@@ -0,0 +1,77 @@
import fs from "node:fs";
import path from "node:path";
/**
* Static, non-transpiled runtime assets referenced by built extension code.
*
* `dest` is the root-package dist path. Package-local runtime builds rewrite it
* under the plugin package's own dist directory.
*/
export const STATIC_EXTENSION_ASSETS = [
{
src: "extensions/acpx/src/runtime-internals/mcp-proxy.mjs",
dest: "dist/extensions/acpx/mcp-proxy.mjs",
},
{
src: "extensions/acpx/src/runtime-internals/error-format.mjs",
dest: "dist/extensions/acpx/error-format.mjs",
},
{
src: "extensions/acpx/src/runtime-internals/mcp-command-line.mjs",
dest: "dist/extensions/acpx/mcp-command-line.mjs",
},
{
src: "extensions/diffs/assets/viewer-runtime.js",
dest: "dist/extensions/diffs/assets/viewer-runtime.js",
},
];
export function listStaticExtensionAssetOutputs(params = {}) {
const assets = params.assets ?? STATIC_EXTENSION_ASSETS;
return assets
.map(({ dest }) => dest.replace(/\\/g, "/"))
.toSorted((left, right) => left.localeCompare(right));
}
export function copyStaticExtensionAssets(params = {}) {
const rootDir = params.rootDir ?? process.cwd();
const assets = params.assets ?? STATIC_EXTENSION_ASSETS;
const fsImpl = params.fs ?? fs;
const warn = params.warn ?? console.warn;
for (const { src, dest } of assets) {
const srcPath = path.join(rootDir, src);
const destPath = path.join(rootDir, dest);
if (fsImpl.existsSync(srcPath)) {
fsImpl.mkdirSync(path.dirname(destPath), { recursive: true });
fsImpl.copyFileSync(srcPath, destPath);
} else {
warn(`[runtime-postbuild] static asset not found, skipping: ${src}`);
}
}
}
export function copyStaticExtensionAssetsForPackage(params) {
const rootDir = params.rootDir ?? process.cwd();
const assets = params.assets ?? STATIC_EXTENSION_ASSETS;
const fsImpl = params.fs ?? fs;
const packagePrefix = `extensions/${params.pluginDir}/`;
const rootDistPrefix = `dist/extensions/${params.pluginDir}/`;
const copied = [];
for (const { src, dest } of assets) {
const normalizedSrc = src.replaceAll("\\", "/");
const normalizedDest = dest.replaceAll("\\", "/");
if (!normalizedSrc.startsWith(packagePrefix) || !normalizedDest.startsWith(rootDistPrefix)) {
continue;
}
const srcPath = path.join(rootDir, src);
if (!fsImpl.existsSync(srcPath)) {
continue;
}
const packageRelativeDest = normalizedDest.slice(rootDistPrefix.length);
const destPath = path.join(rootDir, packagePrefix, "dist", packageRelativeDest);
fsImpl.mkdirSync(path.dirname(destPath), { recursive: true });
fsImpl.copyFileSync(srcPath, destPath);
copied.push(`dist/${packageRelativeDest}`);
}
return copied.toSorted((left, right) => left.localeCompare(right));
}

View File

@@ -75,6 +75,15 @@ log "Resolved mirror dist-tags: ${mirror_dist_tags_csv:-<none>}"
log "Mirror dist-tag auth source: ${mirror_auth_source}"
log "Mirror dist-tag auth requirement: ${mirror_auth_requirement}"
build_package_runtime() {
if [[ "${OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD:-1}" == "0" || "${OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD:-1}" == "false" ]]; then
log "Package-local runtime build: skipped"
return
fi
log "Package-local runtime build: ${package_dir}"
node scripts/lib/plugin-npm-runtime-build.mjs "${package_dir}"
}
mirror_auth_token=""
case "${mirror_auth_source}" in
node-auth-token)
@@ -122,6 +131,8 @@ if [[ "${mode}" == "--dry-run" ]]; then
exit 0
fi
build_package_runtime
if [[ "${mode}" == "--pack-dry-run" ]]; then
node scripts/lib/plugin-npm-package-manifest.mjs --run "${package_dir}" -- \
npm pack --dry-run --json --ignore-scripts

View File

@@ -4,10 +4,16 @@ import { performance } from "node:perf_hooks";
import { fileURLToPath, pathToFileURL } from "node:url";
import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs";
import { copyPluginSdkRootAlias } from "./copy-plugin-sdk-root-alias.mjs";
import {
copyStaticExtensionAssets,
listStaticExtensionAssetOutputs,
} from "./lib/static-extension-assets.mjs";
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
import { stageBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mjs";
import { writeOfficialChannelCatalog } from "./write-official-channel-catalog.mjs";
export { copyStaticExtensionAssets, listStaticExtensionAssetOutputs };
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const ROOT_RUNTIME_ALIAS_PATTERN = /^(?<base>.+\.(?:runtime|contract))-[A-Za-z0-9_-]+\.js$/u;
const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [
@@ -21,60 +27,6 @@ const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [
},
];
/**
* Copy static (non-transpiled) runtime assets that are referenced by their
* source-relative path inside bundled extension code.
*
* Each entry: { src: repo-root-relative source, dest: dist-relative dest }
*/
const STATIC_EXTENSION_ASSETS = [
// acpx MCP proxy — co-deployed alongside the acpx index bundle so that
// `path.resolve(dirname(import.meta.url), "mcp-proxy.mjs")` resolves correctly
// at runtime from the built ACPX extension directory.
{
src: "extensions/acpx/src/runtime-internals/mcp-proxy.mjs",
dest: "dist/extensions/acpx/mcp-proxy.mjs",
},
{
src: "extensions/acpx/src/runtime-internals/error-format.mjs",
dest: "dist/extensions/acpx/error-format.mjs",
},
{
src: "extensions/acpx/src/runtime-internals/mcp-command-line.mjs",
dest: "dist/extensions/acpx/mcp-command-line.mjs",
},
// diffs viewer runtime bundle — co-deployed inside the plugin package so the
// built bundle can resolve `./assets/viewer-runtime.js` from dist.
{
src: "extensions/diffs/assets/viewer-runtime.js",
dest: "dist/extensions/diffs/assets/viewer-runtime.js",
},
];
export function listStaticExtensionAssetOutputs(params = {}) {
const assets = params.assets ?? STATIC_EXTENSION_ASSETS;
return assets
.map(({ dest }) => dest.replace(/\\/g, "/"))
.toSorted((left, right) => left.localeCompare(right));
}
export function copyStaticExtensionAssets(params = {}) {
const rootDir = params.rootDir ?? ROOT;
const assets = params.assets ?? STATIC_EXTENSION_ASSETS;
const fsImpl = params.fs ?? fs;
const warn = params.warn ?? console.warn;
for (const { src, dest } of assets) {
const srcPath = path.join(rootDir, src);
const destPath = path.join(rootDir, dest);
if (fsImpl.existsSync(srcPath)) {
fsImpl.mkdirSync(path.dirname(destPath), { recursive: true });
fsImpl.copyFileSync(srcPath, destPath);
} else {
warn(`[runtime-postbuild] static asset not found, skipping: ${src}`);
}
}
}
export function writeStableRootRuntimeAliases(params = {}) {
const rootDir = params.rootDir ?? ROOT;
const distDir = path.join(rootDir, "dist");
@@ -126,7 +78,12 @@ export function runRuntimePostBuild(params = {}) {
runPhase("bundled plugin runtime overlay", () => stageBundledPluginRuntime(params));
runPhase("stable root runtime aliases", () => writeStableRootRuntimeAliases(params));
runPhase("legacy CLI exit compat chunks", () => writeLegacyCliExitCompatChunks(params));
runPhase("static extension assets", () => copyStaticExtensionAssets(params));
runPhase("static extension assets", () =>
copyStaticExtensionAssets({
rootDir: ROOT,
...params,
}),
);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {

View File

@@ -139,6 +139,24 @@ function readPackageManifest(pluginDir: string): PackageManifest | undefined {
: undefined;
}
function collectRootPackageExcludedExtensionDirsForTest(): readonly string[] {
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")) as {
files?: unknown;
};
if (!Array.isArray(packageJson.files)) {
return [];
}
return packageJson.files
.flatMap((entry) => {
if (typeof entry !== "string") {
return [];
}
const match = /^!dist\/extensions\/([^/]+)\/\*\*$/u.exec(entry);
return match?.[1] ? [match[1]] : [];
})
.toSorted((left, right) => left.localeCompare(right));
}
function collectRepoBundledChannelConfigsForTest(dirName: string) {
const pluginDir = path.join(repoRoot, "extensions", dirName);
const manifest = loadPluginManifest(pluginDir, false);
@@ -228,6 +246,18 @@ describe("bundled plugin metadata", () => {
expect(BUNDLED_RUNTIME_SIDECAR_PATHS).not.toContain("dist/extensions/qa-matrix/runtime-api.js");
});
it("excludes root-package-excluded plugin sidecars from the packaged runtime sidecar baseline", () => {
for (const pluginDir of collectRootPackageExcludedExtensionDirsForTest()) {
expect(BUNDLED_RUNTIME_SIDECAR_PATHS).not.toContain(`dist/extensions/${pluginDir}/index.js`);
expect(BUNDLED_RUNTIME_SIDECAR_PATHS).not.toContain(
`dist/extensions/${pluginDir}/runtime-api.js`,
);
expect(BUNDLED_RUNTIME_SIDECAR_PATHS).not.toContain(
`dist/extensions/${pluginDir}/runtime-setter-api.js`,
);
}
});
it("captures setup-entry metadata for bundled channel plugins", () => {
const discord = listRepoBundledPluginMetadata().find((entry) => entry.dirName === "discord");
expect(discord?.source).toEqual({ source: "./index.ts", built: "index.js" });

View File

@@ -8,14 +8,43 @@ function buildBundledDistArtifactPath(dirName: string, artifact: string): string
return ["dist", "extensions", dirName, artifact].join("/");
}
function collectRootPackageExcludedRuntimeSidecarPluginDirs(rootDir: string): Set<string> {
const packageJsonPath = path.join(rootDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return new Set();
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
files?: unknown;
};
if (!Array.isArray(packageJson.files)) {
return new Set();
}
const excluded = new Set<string>();
for (const entry of packageJson.files) {
if (typeof entry !== "string") {
continue;
}
const match = /^!dist\/extensions\/([^/]+)\/\*\*$/u.exec(entry);
if (match?.[1]) {
excluded.add(match[1]);
}
}
return excluded;
}
export function collectBundledRuntimeSidecarPaths(params?: {
rootDir?: string;
}): readonly string[] {
const rootDir = params?.rootDir ?? process.cwd();
const excludedRuntimeSidecarPluginDirs = new Set([
...NON_PACKAGED_RUNTIME_SIDECAR_PLUGIN_DIRS,
...collectRootPackageExcludedRuntimeSidecarPluginDirs(rootDir),
]);
return listBundledPluginMetadata({
rootDir: params?.rootDir,
rootDir,
includeChannelConfigs: false,
})
.filter((entry) => !NON_PACKAGED_RUNTIME_SIDECAR_PLUGIN_DIRS.has(entry.dirName))
.filter((entry) => !excludedRuntimeSidecarPluginDirs.has(entry.dirName))
.flatMap((entry) =>
(entry.runtimeSidecarArtifacts ?? []).map((artifact) =>
buildBundledDistArtifactPath(entry.dirName, artifact),

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"),
}),
);
});
});