From 11a5b30f3ebeaea2be3bb77176369d14a5ee5a58 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 2 May 2026 22:36:18 -0700 Subject: [PATCH] fix(plugins): build package-local npm runtimes --- scripts/lib/bundled-plugin-build-entries.mjs | 4 +- .../lib/bundled-runtime-sidecar-paths.json | 22 +-- scripts/lib/plugin-npm-package-manifest.mjs | 149 +++++++++++++-- scripts/lib/plugin-npm-runtime-build.mjs | 172 ++++++++++++++++++ scripts/lib/static-extension-assets.mjs | 77 ++++++++ scripts/plugin-npm-publish.sh | 11 ++ scripts/runtime-postbuild.mjs | 67 ++----- src/plugins/bundled-plugin-metadata.test.ts | 30 +++ src/plugins/runtime-sidecar-paths-baseline.ts | 33 +++- test/plugin-npm-package-manifest.test.ts | 68 +++++++ test/plugin-npm-runtime-build.test.ts | 79 ++++++++ 11 files changed, 621 insertions(+), 91 deletions(-) create mode 100644 scripts/lib/plugin-npm-runtime-build.mjs create mode 100644 scripts/lib/static-extension-assets.mjs create mode 100644 test/plugin-npm-runtime-build.test.ts diff --git a/scripts/lib/bundled-plugin-build-entries.mjs b/scripts/lib/bundled-plugin-build-entries.mjs index be9ff2c9e08..c08446ead8e 100644 --- a/scripts/lib/bundled-plugin-build-entries.mjs +++ b/scripts/lib/bundled-plugin-build-entries.mjs @@ -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 []; } diff --git a/scripts/lib/bundled-runtime-sidecar-paths.json b/scripts/lib/bundled-runtime-sidecar-paths.json index 5cb6a3d3b2d..3dcb8aa1151 100644 --- a/scripts/lib/bundled-runtime-sidecar-paths.json +++ b/scripts/lib/bundled-runtime-sidecar-paths.json @@ -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" ] diff --git a/scripts/lib/plugin-npm-package-manifest.mjs b/scripts/lib/plugin-npm-package-manifest.mjs index d32f8b42e41..72283b8054c 100644 --- a/scripts/lib/plugin-npm-package-manifest.mjs +++ b/scripts/lib/plugin-npm-package-manifest.mjs @@ -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"); + } } } diff --git a/scripts/lib/plugin-npm-runtime-build.mjs b/scripts/lib/plugin-npm-runtime-build.mjs new file mode 100644 index 00000000000..3d9af19ce5d --- /dev/null +++ b/scripts/lib/plugin-npm-runtime-build.mjs @@ -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 "); + } + 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; + } +} diff --git a/scripts/lib/static-extension-assets.mjs b/scripts/lib/static-extension-assets.mjs new file mode 100644 index 00000000000..8933d1b7efa --- /dev/null +++ b/scripts/lib/static-extension-assets.mjs @@ -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)); +} diff --git a/scripts/plugin-npm-publish.sh b/scripts/plugin-npm-publish.sh index ece4d8f7e38..4ecec861a9a 100644 --- a/scripts/plugin-npm-publish.sh +++ b/scripts/plugin-npm-publish.sh @@ -75,6 +75,15 @@ log "Resolved mirror dist-tags: ${mirror_dist_tags_csv:-}" 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 diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 8d9b9be9949..548c70e889e 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -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 = /^(?.+\.(?: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) { diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 45fbfc698a8..a4300756f66 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -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" }); diff --git a/src/plugins/runtime-sidecar-paths-baseline.ts b/src/plugins/runtime-sidecar-paths-baseline.ts index d1daf2233b0..771c06a8375 100644 --- a/src/plugins/runtime-sidecar-paths-baseline.ts +++ b/src/plugins/runtime-sidecar-paths-baseline.ts @@ -8,14 +8,43 @@ function buildBundledDistArtifactPath(dirName: string, artifact: string): string return ["dist", "extensions", dirName, artifact].join("/"); } +function collectRootPackageExcludedRuntimeSidecarPluginDirs(rootDir: string): Set { + 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(); + 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), diff --git a/test/plugin-npm-package-manifest.test.ts b/test/plugin-npm-package-manifest.test.ts index f91385ed530..5988b774015 100644 --- a/test/plugin-npm-package-manifest.test.ts +++ b/test/plugin-npm-package-manifest.test.ts @@ -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", + ); + }); }); diff --git a/test/plugin-npm-runtime-build.test.ts b/test/plugin-npm-runtime-build.test.ts new file mode 100644 index 00000000000..77f37e0835b --- /dev/null +++ b/test/plugin-npm-runtime-build.test.ts @@ -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 { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as Record; +} + +function isPublishablePluginPackage(packageJson: Record): 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"), + }), + ); + }); +});