openclaw/scripts/lib/plugin-npm-runtime-build.mts

854 lines
30 KiB
TypeScript

// Builds package-local runtime dist files for publishable bundled plugins.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { TsdownPlugin } from "tsdown";
import { isTypeScriptPackageEntry } from "../../src/plugins/package-entrypoints.ts";
import {
PLUGIN_ACTIVITY_ICON_PATH,
PLUGIN_TOOL_ACTIVITY_ICON_DIR,
PORTABLE_PLUGIN_ICON_PATH,
} from "../../src/plugins/portable-icon-paths.ts";
import {
collectPluginSourceEntries,
collectTopLevelPublicSurfaceEntries,
pluginRuntimeExtension,
resolvePluginRuntimeFormat,
} from "./bundled-plugin-build-entries.mjs";
import { assertRealOutputRoot } from "./output-root-guard.mjs";
import { createPluginInventoryModuleRefsPlugin } from "./plugin-inventory-module-refs.mts";
import { preparePackageRuntimeAssets } from "./plugin-npm-runtime-assets.mts";
import { collectPluginThemeAssetPaths } from "./plugin-theme-assets.mts";
import { isRecord } from "./record-shared.mjs";
const env = {
NODE_ENV: "production",
};
// Supported hosts lack this binding; publish the canonical pure implementation with the plugin.
const BUNDLED_GRAPHEME_SDK_IMPORT = "openclaw/plugin-sdk/text-grapheme";
const QA_PROTOCOL_SDK_IMPORT = "openclaw/plugin-sdk/qa-channel-protocol";
// Only pure helpers missing from supported hosts belong here; runtime owners stay external.
// Retire each bundled binding once the declared host floor includes its SDK export.
const BUNDLED_SDK_EXPORTS: Record<string, Record<string, string[]>> = {
"openclaw/plugin-sdk/runtime-doctor-migrations": {
"src/plugin-sdk/legacy-webhook-listener-migration.ts": [
"createLegacyWebhookListenerDoctorContract",
],
},
"openclaw/plugin-sdk/gateway-config-runtime": {
"src/gateway/gateway-http-route-contracts.ts": ["classifyGatewayProbePath"],
"src/gateway/server/plugins-http/path-context.ts": [
"resolvePluginRoutePathContext",
"isProtectedPluginRoutePathFromContext",
],
},
"openclaw/plugin-sdk/channel-mention-gating": {
"src/channels/mention-gating.ts": ["resolveBotThreadMentionPolicy"],
},
};
const BUNDLED_SDK_PREFIX = "\0openclaw:bundled-sdk:";
const HOST_SDK_PREFIX = "\0openclaw:host-sdk:";
function collectNamedSourceExports(source: string) {
const names = new Set<string>();
const exportClausePattern =
/export\s+(?:type\s+)?\{([^}]*)\}\s*(?:from\s+["'][^"']+["'])?\s*;?/gms;
for (const match of source.matchAll(exportClausePattern)) {
for (const segment of (match[1] ?? "").split(",")) {
const name = segment
.trim()
.replace(/^type\s+/u, "")
.match(/(?:^|\s+as\s+)([A-Za-z_$][\w$]*)$/u)?.[1];
if (name) {
names.add(name);
}
}
}
for (const pattern of [
/\bexport\s+(?:declare\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gu,
/\bexport\s+(?:declare\s+)?const\s+([A-Za-z_$][\w$]*)/gu,
/\bexport\s+type\s+([A-Za-z_$][\w$]*)\s*=/gu,
/\bexport\s+interface\s+([A-Za-z_$][\w$]*)/gu,
/\bexport\s+class\s+([A-Za-z_$][\w$]*)/gu,
]) {
for (const match of source.matchAll(pattern)) {
if (match[1]) {
names.add(match[1]);
}
}
}
return names;
}
function selectAvailableBundledSdkExports(
repoRoot: string,
bundledSdkExports: typeof BUNDLED_SDK_EXPORTS,
) {
return Object.fromEntries(
Object.entries(bundledSdkExports).flatMap(([specifier, sources]) => {
// Trusted current tooling also builds immutable older source roots. Only
// inject bindings owned by that root; later files and exports stay host-owned.
const availableSources = Object.fromEntries(
Object.entries(sources).flatMap(([source, names]) => {
const sourcePath = path.join(repoRoot, source);
if (!fs.existsSync(sourcePath)) {
return [];
}
const exportedNames = collectNamedSourceExports(fs.readFileSync(sourcePath, "utf8"));
const availableNames = names.filter((name) => exportedNames.has(name));
return availableNames.length > 0 ? [[source, availableNames]] : [];
}),
);
return Object.keys(availableSources).length > 0 ? [[specifier, availableSources]] : [];
}),
);
}
function createBundledSdkExportsPlugin(
repoRoot: string,
bundledSdkExports: typeof BUNDLED_SDK_EXPORTS,
bundleTelegramLifecycle: boolean,
): TsdownPlugin {
return {
name: "openclaw:bundled-sdk-exports",
resolveId(id, importer) {
// Partial-delivery errors must use the host's configured redactor and secret registry.
if (
bundleTelegramLifecycle &&
id === "../../infra/errors.js" &&
importer &&
path.resolve(importer) ===
path.join(repoRoot, "src/channels/turn/partial-delivery-error.ts")
) {
return { id: "openclaw/plugin-sdk/error-runtime", external: true };
}
if (Object.hasOwn(bundledSdkExports, id)) {
return `${BUNDLED_SDK_PREFIX}${id}`;
}
if (id.startsWith(HOST_SDK_PREFIX)) {
return { id: id.slice(HOST_SDK_PREFIX.length), external: true };
}
if (id.startsWith(BUNDLED_SDK_PREFIX)) {
return id;
}
return undefined;
},
load(id) {
if (!id.startsWith(BUNDLED_SDK_PREFIX)) {
return undefined;
}
const specifier = id.slice(BUNDLED_SDK_PREFIX.length);
const sources = bundledSdkExports[specifier];
if (!sources) {
return undefined;
}
return [
...Object.entries(sources).map(
([source, names]) =>
`export { ${names.join(", ")} } from ${JSON.stringify(path.join(repoRoot, source))};`,
),
// The distinct ID avoids resolving the host passthrough back into this facade.
`export * from ${JSON.stringify(`${HOST_SDK_PREFIX}${specifier}`)};`,
].join("\n");
},
};
}
type JsonRecord = Record<string, unknown>;
export type PluginPackageJson = JsonRecord & {
dependencies?: JsonRecord;
openclaw?: {
assetScripts?: { build?: unknown };
build?: {
bundledDist?: unknown;
openclawVersion?: unknown;
runtimeFormat?: unknown;
workerEntries?: unknown;
};
compat?: { pluginApi?: unknown };
release?: {
bundleRuntimeDependencies?: unknown;
publishToClawHub?: unknown;
publishToNpm?: unknown;
};
[key: string]: unknown;
};
optionalDependencies?: JsonRecord;
peerDependencies?: JsonRecord;
};
type RuntimeBuildFormat = "esm" | "cjs";
type PluginNpmRuntimeBuildParams = {
repoRoot?: string;
packageDir: string;
logLevel?: "silent" | "error" | "warn" | "info";
profile?: "qa-gateway-fixture";
};
function readJsonFile(filePath: string) {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as PluginPackageJson;
}
/** Return whether a plugin package publishes through an artifact release workflow. */
function isPublishablePluginPackage(packageJson: PluginPackageJson) {
return (
packageJson.openclaw?.release?.publishToNpm === true ||
packageJson.openclaw?.release?.publishToClawHub === true
);
}
function normalizePackageEntry(value: unknown) {
return typeof value === "string" ? value.trim().replaceAll("\\", "/") : "";
}
export function toPackageRuntimeEntry(entry: string, runtimeFormat: RuntimeBuildFormat = "esm") {
const normalized = normalizePackageEntry(entry).replace(/^\.\//u, "");
return `./dist/${normalized.replace(/\.[^.]+$/u, pluginRuntimeExtension(runtimeFormat))}`;
}
function collectExternalDependencyNames(packageJson: PluginPackageJson) {
return new Set(
[
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.peerDependencies ?? {}),
...Object.keys(packageJson.optionalDependencies ?? {}),
].filter(Boolean),
);
}
function getStringRecord(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value).filter(
([, entryValue]) => typeof entryValue === "string" && entryValue.trim().length > 0,
),
);
}
function createNeverBundleDependencyMatcher(
packageJson: PluginPackageJson,
bundledSdkImports: ReadonlySet<string>,
) {
const externalDependencies = collectExternalDependencyNames(packageJson);
return (id: string) => {
if (bundledSdkImports.has(id)) {
return false;
}
if (id === "openclaw" || id.startsWith("openclaw/")) {
return true;
}
for (const dependency of externalDependencies) {
if (id === dependency || id.startsWith(`${dependency}/`)) {
return true;
}
}
return false;
};
}
const HOST_PLUGIN_SDK_IMPORT_RE =
/(?:\bfrom\s+|\bimport\s*(?:\(\s*)?|\b(?:require|_+require\d*)\(\s*)["'](openclaw\/plugin-sdk\/[^"']+)["']/gu;
function listRuntimeJavaScriptFiles(rootDir: string): string[] {
if (!fs.existsSync(rootDir)) {
return [];
}
return fs
.readdirSync(rootDir, { withFileTypes: true })
.flatMap((entry) => {
const entryPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
return listRuntimeJavaScriptFiles(entryPath);
}
return /\.(?:c|m)?js$/u.test(entry.name) ? [entryPath] : [];
})
.toSorted((left, right) => left.localeCompare(right));
}
/**
* List host SDK imports emitted by a built plugin runtime but absent from package exports.
* @param {{ repoRoot: string; outDir: string }} plan
*/
export function listMissingPluginNpmRuntimeHostExports(plan: { repoRoot: string; outDir: string }) {
const hostImports = new Set<string>();
for (const runtimePath of listRuntimeJavaScriptFiles(plan.outDir)) {
const source = fs.readFileSync(runtimePath, "utf8");
for (const match of source.matchAll(HOST_PLUGIN_SDK_IMPORT_RE)) {
const specifier = match[1];
if (specifier) {
hostImports.add(specifier);
}
}
}
if (hostImports.size === 0) {
return [];
}
const hostPackageJson = readJsonFile(path.join(plan.repoRoot, "package.json"));
const hostExports = new Set(Object.keys(hostPackageJson.exports ?? {}));
return [...hostImports]
.filter((specifier) => !hostExports.has(specifier.replace(/^openclaw/u, ".")))
.toSorted((left, right) => left.localeCompare(right));
}
function packageEntryKey(entry: string) {
return normalizePackageEntry(entry)
.replace(/^\.\//u, "")
.replace(/\.[^.]+$/u, "");
}
function resolvePackageDir(repoRoot: string, packageDir: string) {
return path.isAbsolute(packageDir) ? packageDir : path.resolve(repoRoot, packageDir);
}
function packageRelativePathExists(packageDir: string, relativePath: string) {
return fs.existsSync(path.join(packageDir, relativePath));
}
/**
* List extension package dirs whose package metadata enables artifact publishing.
* @internal Shared repository-script contract.
*/
export function listPublishablePluginPackageDirs(params: { repoRoot?: string } = {}) {
const repoRoot = path.resolve(params.repoRoot ?? ".");
const extensionsRoot = path.join(repoRoot, "extensions");
return fs
.readdirSync(extensionsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join("extensions", entry.name))
.filter((packageDir) => {
const packageJsonPath = path.join(repoRoot, packageDir, "package.json");
return (
fs.existsSync(packageJsonPath) && isPublishablePluginPackage(readJsonFile(packageJsonPath))
);
})
.toSorted((left, right) => left.localeCompare(right));
}
/** List package-local runtime output files expected from a runtime build plan. */
export function listPluginNpmRuntimeBuildOutputs(plan: {
runtimeFormat: RuntimeBuildFormat;
entry: Record<string, string>;
}) {
const extension = pluginRuntimeExtension(plan.runtimeFormat);
return Object.keys(plan.entry)
.map((entryKey) => `./dist/${entryKey}${extension}`)
.toSorted((left, right) => left.localeCompare(right));
}
function rewriteCommonJsRuntimeSpecifiers(plan: PluginNpmRuntimeBuildPlan) {
if (plan.runtimeFormat !== "cjs") {
return;
}
const specifierRewrites = new Map(
plan.runtimeBuildOutputs.map((output) => {
const cjsSpecifier = output.replace(/^\.\/dist\//u, "./");
return [cjsSpecifier.replace(/\.cjs$/u, ".js"), cjsSpecifier];
}),
);
for (const output of plan.runtimeBuildOutputs) {
const outputPath = path.join(plan.packageDir, output.replace(/^\.\//u, ""));
let text = fs.readFileSync(outputPath, "utf8");
const original = text;
// Source entries stay .js for the root bundled build; package-local CJS
// artifacts must point at their generated .cjs sidecars instead.
for (const [fromSpecifier, toSpecifier] of specifierRewrites) {
text = text.replaceAll(
`specifier: ${JSON.stringify(fromSpecifier)}`,
`specifier: ${JSON.stringify(toSpecifier)}`,
);
}
if (text !== original) {
fs.writeFileSync(outputPath, text, "utf8");
}
}
}
/** Resolve package `files` entries needed for runtime build outputs and plugin metadata. */
function resolvePluginNpmRuntimePackageFiles(plan: {
packageJson: PluginPackageJson;
packageDir: string;
manifest: JsonRecord;
}) {
const merged = new Set(
Array.isArray(plan.packageJson.files)
? plan.packageJson.files.filter((entry) => typeof entry === "string")
: [],
);
merged.add("dist/**");
for (const file of [
"openclaw.plugin.json",
"README.md",
"SKILL.md",
PORTABLE_PLUGIN_ICON_PATH,
PLUGIN_ACTIVITY_ICON_PATH,
]) {
if (packageRelativePathExists(plan.packageDir, file)) {
merged.add(file);
}
}
if (packageRelativePathExists(plan.packageDir, PLUGIN_TOOL_ACTIVITY_ICON_DIR)) {
merged.add(`${PLUGIN_TOOL_ACTIVITY_ICON_DIR}/*.svg`);
}
if (packageRelativePathExists(plan.packageDir, "skills")) {
merged.add("skills/**");
}
for (const file of collectPluginThemeAssetPaths(plan.manifest)) {
merged.add(file);
}
return [...merged];
}
function normalizeOpenClawPeerRange(value: unknown) {
const normalized = normalizePackageEntry(value);
if (!normalized) {
return "";
}
return /^[<>=~^*]|^(?:workspace|npm|file|link|portal|catalog):/u.test(normalized)
? normalized
: `>=${normalized}`;
}
function resolveOpenClawPeerRange(
packageJson: PluginPackageJson,
rootPackageJson: PluginPackageJson | undefined,
) {
return (
normalizeOpenClawPeerRange(packageJson.openclaw?.compat?.pluginApi) ||
normalizeOpenClawPeerRange(packageJson.peerDependencies?.openclaw) ||
normalizeOpenClawPeerRange(packageJson.openclaw?.build?.openclawVersion) ||
normalizeOpenClawPeerRange(rootPackageJson?.version) ||
normalizeOpenClawPeerRange(packageJson.version)
);
}
/** Resolve package peer dependency metadata for the OpenClaw plugin API. */
function resolvePluginNpmRuntimePackagePeerMetadata(plan: {
packageJson: PluginPackageJson;
rootPackageJson: PluginPackageJson | undefined;
pluginDir: string;
}) {
const openclawPeerRange = resolveOpenClawPeerRange(plan.packageJson, plan.rootPackageJson);
if (!openclawPeerRange) {
throw new Error(
`cannot infer openclaw peerDependency range for ${plan.pluginDir}; set openclaw.compat.pluginApi or package version`,
);
}
const existingPeerDependencies = getStringRecord(plan.packageJson.peerDependencies);
const existingPeerDependenciesMeta = isRecord(plan.packageJson.peerDependenciesMeta)
? plan.packageJson.peerDependenciesMeta
: {};
const existingOpenClawMeta = isRecord(existingPeerDependenciesMeta.openclaw)
? existingPeerDependenciesMeta.openclaw
: {};
return {
peerDependencies: {
...existingPeerDependencies,
openclaw: openclawPeerRange,
},
peerDependenciesMeta: {
...existingPeerDependenciesMeta,
openclaw: {
...existingOpenClawMeta,
optional: true,
},
},
};
}
function projectQaGatewayFixture(
packageJson: PluginPackageJson,
manifest: JsonRecord,
): { packageJson: PluginPackageJson; manifest: JsonRecord; sourceEntries: string[] } {
const id = manifest.id;
if (
packageJson.private !== true ||
(id !== "qa-lab" && id !== "qa-channel") ||
packageJson.name !== `@openclaw/${id}`
) {
throw new Error(
"The QA Gateway fixture profile requires the private qa-lab or qa-channel package.",
);
}
const {
devDependencies: _devDependencies,
scripts: _scripts,
exports: _exports,
files: _files,
dependencies: _dependencies,
optionalDependencies: _optionalDependencies,
...metadata
} = packageJson;
const {
build: _build,
release: _release,
runtimeExtensions: _runtimeExtensions,
runtimeSetupEntry: _runtimeSetupEntry,
setupEntry: _setupEntry,
...openclaw
} = packageJson.openclaw ?? {};
const entry = id === "qa-lab" ? "./gateway-entry.ts" : "./index.ts";
const setupEntry = id === "qa-channel" ? "./setup-entry.ts" : undefined;
const dependencies: JsonRecord = {};
if (id === "qa-channel") {
for (const name of ["typebox", "zod"]) {
const version = packageJson.dependencies?.[name];
if (typeof version !== "string" || !version) {
throw new Error(`QA Channel fixture dependency is missing: ${name}`);
}
dependencies[name] = version;
}
}
const { cliCommands: _cliCommands, ...gatewayManifest } = manifest;
return {
packageJson: {
...metadata,
dependencies,
files: ["dist/**", "openclaw.plugin.json"],
openclaw: { ...openclaw, extensions: [entry], ...(setupEntry ? { setupEntry } : {}) },
},
manifest: gatewayManifest,
// The channel entry stores these module names as data rather than import edges.
sourceEntries:
id === "qa-lab"
? [entry]
: [
entry,
"./setup-entry.ts",
"./channel-plugin-api.ts",
"./setup-plugin-api.ts",
"./api.ts",
],
};
}
/** Resolve the package-local runtime build plan for one plugin package. */
export function resolvePluginNpmRuntimeBuildPlan(params: PluginNpmRuntimeBuildParams) {
const repoRoot = path.resolve(params.repoRoot ?? ".");
const packageDir = resolvePackageDir(repoRoot, params.packageDir);
const packageJsonPath = path.join(packageDir, "package.json");
const sourcePackageJson = readJsonFile(packageJsonPath);
const rootPackageJsonPath = path.join(repoRoot, "package.json");
const rootPackageJson = fs.existsSync(rootPackageJsonPath)
? readJsonFile(rootPackageJsonPath)
: undefined;
// Compilation also serves private source-checkout plugins. Publication selection
// belongs to listPublishablePluginPackageDirs, not the runtime graph builder.
if (!Array.isArray(sourcePackageJson.openclaw?.extensions)) {
return null;
}
const manifestPath = path.join(packageDir, "openclaw.plugin.json");
const sourceManifest = fs.existsSync(manifestPath) ? readJsonFile(manifestPath) : {};
const projection =
params.profile === "qa-gateway-fixture"
? projectQaGatewayFixture(sourcePackageJson, sourceManifest)
: undefined;
const packageJson = projection?.packageJson ?? sourcePackageJson;
const manifest = projection?.manifest ?? sourceManifest;
const runtimeFormat = resolvePluginRuntimeFormat(packageJson);
const packageEntries = collectPluginSourceEntries(packageJson, manifest).map(
normalizePackageEntry,
);
const requiresRuntimeBuild = packageEntries.some(isTypeScriptPackageEntry);
if (!requiresRuntimeBuild) {
return null;
}
const pluginDir = path.basename(packageDir);
const sourceEntries =
projection?.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, "")),
]),
);
const setupEntry = normalizePackageEntry(packageJson.openclaw?.setupEntry);
const plan = {
repoRoot,
packageDir,
pluginDir,
packageJson,
manifest,
profile: params.profile,
rootPackageJson,
sourceEntries,
entry,
outDir: path.join(packageDir, "dist"),
runtimeFormat,
runtimeExtensions: (Array.isArray(packageJson.openclaw?.extensions)
? packageJson.openclaw.extensions
: []
)
.map(normalizePackageEntry)
.filter(Boolean)
.map((runtimeEntry) => toPackageRuntimeEntry(runtimeEntry, runtimeFormat)),
runtimeSetupEntry: setupEntry ? toPackageRuntimeEntry(setupEntry, runtimeFormat) : undefined,
};
return {
...plan,
runtimeBuildOutputs: listPluginNpmRuntimeBuildOutputs(plan),
packageFiles: resolvePluginNpmRuntimePackageFiles({ ...plan, manifest }),
packagePeerMetadata: resolvePluginNpmRuntimePackagePeerMetadata(plan),
};
}
export type PluginNpmRuntimeBuildPlan = NonNullable<
ReturnType<typeof resolvePluginNpmRuntimeBuildPlan>
>;
/**
* Build isolated runtime files and static assets for publication or source-checkout use.
* @internal Shared repository-script contract.
*/
export async function buildPluginNpmRuntime(params: PluginNpmRuntimeBuildParams) {
const plan = resolvePluginNpmRuntimeBuildPlan(params);
if (!plan) {
return null;
}
// The 9.6 host lacks Telegram's final-observation contract. This canonical factory
// owns isolated per-turn state; its host error formatter stays external below.
// Retire this binding when Telegram's declared host floor includes that contract.
const bundleTelegramLifecycle = plan.packageJson.name === "@openclaw/telegram";
const bundledSdkExports = selectAvailableBundledSdkExports(plan.repoRoot, {
...BUNDLED_SDK_EXPORTS,
...(bundleTelegramLifecycle
? {
"openclaw/plugin-sdk/channel-outbound": {
"src/channels/message/live.ts": ["createLivePreviewLifecycle"],
},
}
: {}),
});
const bundledSdkImports = {
[BUNDLED_GRAPHEME_SDK_IMPORT]: path.join(
plan.repoRoot,
"packages/normalization-core/src/grapheme.ts",
),
...(plan.profile === "qa-gateway-fixture"
? {
[QA_PROTOCOL_SDK_IMPORT]: path.join(
plan.repoRoot,
"src/plugin-sdk/qa-channel-protocol.ts",
),
}
: {}),
};
const bundledSdkSpecifiers = new Set([
...Object.keys(bundledSdkImports),
...Object.keys(bundledSdkExports),
]);
const { build } = await import("tsdown");
assertRealOutputRoot(plan.outDir);
fs.rmSync(plan.outDir, { recursive: true, force: true });
await build({
clean: false,
config: false,
dts: false,
alias: bundledSdkImports,
deps: {
alwaysBundle: (id) => bundledSdkSpecifiers.has(id),
neverBundle: createNeverBundleDependencyMatcher(plan.packageJson, bundledSdkSpecifiers),
},
entry: plan.entry,
plugins: [
createBundledSdkExportsPlugin(plan.repoRoot, bundledSdkExports, bundleTelegramLifecycle),
createPluginInventoryModuleRefsPlugin(plan.packageDir),
],
outputOptions: {
// Published plugins still support hosts predating these private source facades.
paths: {
"openclaw/plugin-sdk/media-ffmpeg": "openclaw/plugin-sdk/media-runtime",
"openclaw/plugin-sdk/realtime-voice-playback": "openclaw/plugin-sdk/realtime-voice",
},
chunkFileNames: `.setup/[name]-[hash]${plan.runtimeFormat === "cjs" ? ".cjs" : ".mjs"}`,
entryFileNames: (chunk) =>
Object.hasOwn(plan.entry, chunk.name)
? `[name]${pluginRuntimeExtension(plan.runtimeFormat)}`
: `.setup/[name]-[hash]${plan.runtimeFormat === "cjs" ? ".cjs" : ".mjs"}`,
},
env,
fixedExtension: plan.runtimeFormat === "cjs",
format: plan.runtimeFormat,
logLevel: params.logLevel ?? "info",
outDir: plan.outDir,
platform: "node",
});
const missingHostExports = listMissingPluginNpmRuntimeHostExports(plan);
if (missingHostExports.length > 0) {
throw new Error(
`${plan.pluginDir} runtime imports missing OpenClaw host exports: ${missingHostExports.join(", ")}`,
);
}
rewriteCommonJsRuntimeSpecifiers(plan);
return {
...plan,
...preparePackageRuntimeAssets(plan),
};
}
async function preparePluginNativeImport(params: PluginNpmRuntimeBuildParams) {
// Source setup is opt-in; publication and root builds must remain artifact-only.
const { readRootJsonObjectSync } = await import("../../src/infra/json-files.js");
const { linkOpenClawPeerDependencies, resolveOpenClawHostDependency } =
await import("../../src/plugins/plugin-peer-link.js");
const { isSourceCheckoutRoot } = await import("../postinstall-bundled-plugins.mjs");
const repoRoot = fs.realpathSync(params.repoRoot ?? ".");
const hostManifest = readRootJsonObjectSync({
rootDir: repoRoot,
relativePath: "package.json",
boundaryLabel: "OpenClaw source checkout",
});
if (
!hostManifest.ok ||
hostManifest.value.name !== "openclaw" ||
!isSourceCheckoutRoot({ packageRoot: repoRoot })
) {
throw new Error("Native-import preparation must run from an OpenClaw source checkout root.");
}
const packageDir = path.resolve(repoRoot, params.packageDir);
if (
path.dirname(packageDir) !== path.join(repoRoot, "extensions") ||
!fs.lstatSync(packageDir, { throwIfNoEntry: false })?.isDirectory() ||
fs.realpathSync(packageDir) !== packageDir
) {
throw new Error(
"Select a real immediate extensions/<package> directory in this checkout; symlinked packages are not supported.",
);
}
const manifest = readRootJsonObjectSync({
rootDir: packageDir,
relativePath: "package.json",
boundaryLabel: "selected source plugin package",
});
if (!manifest.ok) {
throw new Error(
`Could not safely read ${packageDir}/package.json; use a regular file containing a JSON object.`,
);
}
const dependency = resolveOpenClawHostDependency(manifest.value);
if (!dependency) {
throw new Error(
`${params.packageDir} does not declare openclaw in peerDependencies, optionalDependencies, or dependencies; no host link to prepare.`,
);
}
if (
!fs.statSync(path.join(repoRoot, "dist/plugin-sdk"), { throwIfNoEntry: false })?.isDirectory()
) {
throw new Error("Host SDK output is missing; build OpenClaw before preparing native imports.");
}
const runtimeFormat = resolvePluginRuntimeFormat(manifest.value);
const outDir = path.join(packageDir, "dist");
for (const entry of collectPluginSourceEntries(manifest.value)) {
const output = path.resolve(packageDir, toPackageRuntimeEntry(entry, runtimeFormat));
const relative = path.relative(outDir, output);
if (
relative.startsWith(`..${path.sep}`) ||
relative === ".." ||
path.isAbsolute(relative) ||
!fs.statSync(output, { throwIfNoEntry: false })?.isFile()
) {
throw new Error(
`Missing package-local runtime output for ${entry}; run node scripts/lib/plugin-npm-runtime-build.mjs ${params.packageDir} first.`,
);
}
}
const result = await linkOpenClawPeerDependencies({
installedDir: packageDir,
hostRoot: repoRoot,
peerDependencies: { openclaw: dependency.spec },
logger: { warn: (message) => console.error(message) },
});
if (result.skipped > 0) {
throw new Error(
`Could not prepare ${params.packageDir}: inspect node_modules/openclaw and the warning above; move conflicting paths aside and rerun --prepare-native-import.`,
);
}
console.error(
`[plugin-npm-runtime-build] prepared ${path.basename(packageDir)} host link for native imports (artifacts unchanged)`,
);
}
function usage() {
return (
"usage: node scripts/lib/plugin-npm-runtime-build.mjs <package-dir> [--prepare-native-import | --qa-gateway-fixture]\n" +
" --prepare-native-import Prepare an already-built source package without rebuilding artifacts; run from the checkout root.\n" +
" --qa-gateway-fixture Build the private QA plugin Gateway graph for installed-candidate tests."
);
}
type PluginNpmRuntimeBuildArgs =
| { help: true; packageDir: string }
| { help?: false; packageDir: string; prepareNativeImport: true }
| {
help?: false;
packageDir: string;
prepareNativeImport?: false;
profile?: "qa-gateway-fixture";
};
export function parseArgs(argv: string[]): PluginNpmRuntimeBuildArgs {
const args = argv[0] === "--" ? argv.slice(1) : [...argv];
const prepareIndex = args.indexOf("--prepare-native-import");
if (prepareIndex !== -1) {
args.splice(prepareIndex, 1);
}
const fixtureIndex = args.indexOf("--qa-gateway-fixture");
if (fixtureIndex !== -1) {
args.splice(fixtureIndex, 1);
}
if (prepareIndex !== -1 && fixtureIndex !== -1) {
throw new Error("QA Gateway fixtures cannot prepare source-native host imports.");
}
const packageDir = args[0];
if (packageDir === "--help" || packageDir === "-h") {
return { help: true, packageDir: "" };
}
if (!packageDir || packageDir.startsWith("-")) {
throw new Error(usage());
}
const extraArg = args[1];
if (args.length > 1) {
throw new Error(`unexpected plugin npm runtime build argument: ${extraArg}`);
}
return prepareIndex !== -1
? { packageDir, prepareNativeImport: true }
: { packageDir, ...(fixtureIndex !== -1 ? { profile: "qa-gateway-fixture" as const } : {}) };
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
process.exit(0);
}
const { packageDir } = args;
const result = args.prepareNativeImport
? await preparePluginNativeImport({ packageDir })
: await buildPluginNpmRuntime({ packageDir, profile: args.profile });
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;
}
}