mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix(plugins): build package-local npm runtimes
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
172
scripts/lib/plugin-npm-runtime-build.mjs
Normal file
172
scripts/lib/plugin-npm-runtime-build.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
77
scripts/lib/static-extension-assets.mjs
Normal file
77
scripts/lib/static-extension-assets.mjs
Normal 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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
79
test/plugin-npm-runtime-build.test.ts
Normal file
79
test/plugin-npm-runtime-build.test.ts
Normal 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"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user