From 70b235f3122af3eda385a6832c8765864c3e6ddd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 23 Mar 2026 08:21:50 -0700 Subject: [PATCH] fix(release): ship bundled plugins in pack artifacts --- .github/workflows/ci.yml | 4 + scripts/copy-bundled-plugin-metadata.mjs | 9 +- scripts/lib/bundled-plugin-build-entries.mjs | 95 +++++++++++++++++++ scripts/lib/optional-bundled-clusters.d.mts | 7 +- scripts/lib/optional-bundled-clusters.d.ts | 7 +- scripts/lib/optional-bundled-clusters.mjs | 12 ++- scripts/release-check.ts | 7 +- src/infra/tsdown-config.test.ts | 3 + .../copy-bundled-plugin-metadata.test.ts | 21 ++++ tsdown.config.ts | 56 +---------- 10 files changed, 157 insertions(+), 64 deletions(-) create mode 100644 scripts/lib/bundled-plugin-build-entries.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7e5c97d1a9..5bf70fbff5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,9 @@ jobs: - name: Build dist run: pnpm build + - name: Build Control UI + run: pnpm ui:build + - name: Upload dist artifact uses: actions/upload-artifact@v7 with: @@ -261,6 +264,7 @@ jobs: cache_key_suffix: "node22" command: | pnpm build + pnpm ui:build node openclaw.mjs --help node openclaw.mjs status --json --timeout 1 pnpm test:build:singleton diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index e0add010b15..a50c439ca37 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -189,7 +189,11 @@ export function copyBundledPluginMetadata(params = {}) { const pluginDir = path.join(extensionsRoot, dirent.name); const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); const distPluginDir = path.join(distExtensionsRoot, dirent.name); - if (!shouldBuildBundledCluster(dirent.name, env)) { + const packageJsonPath = path.join(pluginDir, "package.json"); + const packageJson = fs.existsSync(packageJsonPath) + ? JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) + : undefined; + if (!shouldBuildBundledCluster(dirent.name, env, { packageJson })) { removePathIfExists(distPluginDir); continue; } @@ -219,13 +223,10 @@ export function copyBundledPluginMetadata(params = {}) { : manifest; writeTextFileIfChanged(distManifestPath, `${JSON.stringify(bundledManifest, null, 2)}\n`); - const packageJsonPath = path.join(pluginDir, "package.json"); if (!fs.existsSync(packageJsonPath)) { removeFileIfExists(distPackageJsonPath); continue; } - - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); if (packageJson.openclaw && "extensions" in packageJson.openclaw) { packageJson.openclaw = { ...packageJson.openclaw, diff --git a/scripts/lib/bundled-plugin-build-entries.mjs b/scripts/lib/bundled-plugin-build-entries.mjs new file mode 100644 index 00000000000..7b1705c180c --- /dev/null +++ b/scripts/lib/bundled-plugin-build-entries.mjs @@ -0,0 +1,95 @@ +import fs from "node:fs"; +import path from "node:path"; +import { shouldBuildBundledCluster } from "./optional-bundled-clusters.mjs"; + +function readBundledPluginPackageJson(packageJsonPath) { + if (!fs.existsSync(packageJsonPath)) { + return null; + } + try { + return JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + } catch { + return null; + } +} + +function collectPluginSourceEntries(packageJson) { + let packageEntries = Array.isArray(packageJson?.openclaw?.extensions) + ? packageJson.openclaw.extensions.filter( + (entry) => typeof entry === "string" && entry.trim().length > 0, + ) + : []; + const setupEntry = + typeof packageJson?.openclaw?.setupEntry === "string" && + packageJson.openclaw.setupEntry.trim().length > 0 + ? packageJson.openclaw.setupEntry + : undefined; + if (setupEntry) { + packageEntries = Array.from(new Set([...packageEntries, setupEntry])); + } + return packageEntries.length > 0 ? packageEntries : ["./index.ts"]; +} + +export function collectBundledPluginBuildEntries(params = {}) { + const cwd = params.cwd ?? process.cwd(); + const env = params.env ?? process.env; + const extensionsRoot = path.join(cwd, "extensions"); + const entries = []; + + for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + + const pluginDir = path.join(extensionsRoot, dirent.name); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + if (!fs.existsSync(manifestPath)) { + continue; + } + + const packageJsonPath = path.join(pluginDir, "package.json"); + const packageJson = readBundledPluginPackageJson(packageJsonPath); + if (!shouldBuildBundledCluster(dirent.name, env, { packageJson })) { + continue; + } + + entries.push({ + id: dirent.name, + hasPackageJson: packageJson !== null, + packageJson, + sourceEntries: collectPluginSourceEntries(packageJson), + }); + } + + return entries; +} + +export function listBundledPluginBuildEntries(params = {}) { + return Object.fromEntries( + collectBundledPluginBuildEntries(params).flatMap(({ id, sourceEntries }) => + sourceEntries.map((entry) => { + const normalizedEntry = entry.replace(/^\.\//, ""); + const entryKey = `extensions/${id}/${normalizedEntry.replace(/\.[^.]+$/u, "")}`; + return [entryKey, path.join("extensions", id, normalizedEntry)]; + }), + ), + ); +} + +export function listBundledPluginPackArtifacts(params = {}) { + const entries = collectBundledPluginBuildEntries(params); + const artifacts = new Set(); + + for (const { id, hasPackageJson, sourceEntries } of entries) { + artifacts.add(`dist/extensions/${id}/openclaw.plugin.json`); + if (hasPackageJson) { + artifacts.add(`dist/extensions/${id}/package.json`); + } + for (const entry of sourceEntries) { + const normalizedEntry = entry.replace(/^\.\//, "").replace(/\.[^.]+$/u, ""); + artifacts.add(`dist/extensions/${id}/${normalizedEntry}.js`); + } + } + + return [...artifacts].toSorted((left, right) => left.localeCompare(right)); +} diff --git a/scripts/lib/optional-bundled-clusters.d.mts b/scripts/lib/optional-bundled-clusters.d.mts index 425e241ced7..7ba3dddcb59 100644 --- a/scripts/lib/optional-bundled-clusters.d.mts +++ b/scripts/lib/optional-bundled-clusters.d.mts @@ -3,4 +3,9 @@ export const optionalBundledClusterSet: Set; export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED"; export function isOptionalBundledCluster(cluster: string): boolean; export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean; -export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean; +export function hasReleasedBundledInstall(packageJson: unknown): boolean; +export function shouldBuildBundledCluster( + cluster: string, + env?: NodeJS.ProcessEnv, + options?: { packageJson?: unknown }, +): boolean; diff --git a/scripts/lib/optional-bundled-clusters.d.ts b/scripts/lib/optional-bundled-clusters.d.ts index 425e241ced7..7ba3dddcb59 100644 --- a/scripts/lib/optional-bundled-clusters.d.ts +++ b/scripts/lib/optional-bundled-clusters.d.ts @@ -3,4 +3,9 @@ export const optionalBundledClusterSet: Set; export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED"; export function isOptionalBundledCluster(cluster: string): boolean; export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean; -export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean; +export function hasReleasedBundledInstall(packageJson: unknown): boolean; +export function shouldBuildBundledCluster( + cluster: string, + env?: NodeJS.ProcessEnv, + options?: { packageJson?: unknown }, +): boolean; diff --git a/scripts/lib/optional-bundled-clusters.mjs b/scripts/lib/optional-bundled-clusters.mjs index 53ca72009b6..bc73d6b4529 100644 --- a/scripts/lib/optional-bundled-clusters.mjs +++ b/scripts/lib/optional-bundled-clusters.mjs @@ -26,6 +26,16 @@ export function shouldIncludeOptionalBundledClusters(env = process.env) { return env[OPTIONAL_BUNDLED_BUILD_ENV] === "1"; } -export function shouldBuildBundledCluster(cluster, env = process.env) { +export function hasReleasedBundledInstall(packageJson) { + return ( + typeof packageJson?.openclaw?.install?.npmSpec === "string" && + packageJson.openclaw.install.npmSpec.trim().length > 0 + ); +} + +export function shouldBuildBundledCluster(cluster, env = process.env, options = {}) { + if (hasReleasedBundledInstall(options.packageJson)) { + return true; + } return shouldIncludeOptionalBundledClusters(env) || !isOptionalBundledCluster(cluster); } diff --git a/scripts/release-check.ts b/scripts/release-check.ts index f7f36373a49..45f244a64bf 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -9,6 +9,7 @@ import { type BundledExtension, type ExtensionPackageJson as PackageJson, } from "./lib/bundled-extension-manifest.ts"; +import { listBundledPluginPackArtifacts } from "./lib/bundled-plugin-build-entries.mjs"; import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs"; import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts"; @@ -21,9 +22,11 @@ const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], ["dist/entry.js", "dist/entry.mjs"], ...listPluginSdkDistArtifacts(), + ...listBundledPluginPackArtifacts(), "dist/plugin-sdk/compat.js", "dist/plugin-sdk/root-alias.cjs", "dist/build-info.json", + "dist/control-ui/index.html", ]; const forbiddenPrefixes = ["dist-runtime/", "dist/OpenClaw.app/"]; // 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory @@ -85,7 +88,7 @@ export function collectForbiddenPackPaths(paths: Iterable): string[] { forbiddenPrefixes.some((prefix) => path.startsWith(prefix)) || (/node_modules\//.test(path) && !isAllowedBundledPluginNodeModulesPath(path)), ) - .toSorted(); + .toSorted((left, right) => left.localeCompare(right)); } function formatMiB(bytes: number): string { @@ -303,7 +306,7 @@ async function main() { } return paths.has(group) ? [] : [group]; }) - .toSorted(); + .toSorted((left, right) => left.localeCompare(right)); const forbidden = collectForbiddenPackPaths(paths); const sizeErrors = collectPackUnpackedSizeErrors(results); diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 8abe152d4ad..9cc4d0ab40c 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -40,6 +40,9 @@ describe("tsdown config", () => { "plugin-sdk/compat", "plugin-sdk/index", "extensions/openai/index", + "extensions/matrix/index", + "extensions/msteams/index", + "extensions/whatsapp/index", "bundled/boot-md/handler", ]), ); diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 5c4163610a1..f85254e2e98 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -361,4 +361,25 @@ describe("copyBundledPluginMetadata", () => { expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx"))).toBe(false); }); + + it("still bundles previously released optional plugins without the opt-in env", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-released-optional-"); + const pluginDir = path.join(repoRoot, "extensions", "whatsapp"); + fs.mkdirSync(pluginDir, { recursive: true }); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), { + id: "whatsapp", + configSchema: { type: "object" }, + }); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/whatsapp", + openclaw: { + extensions: ["./index.ts"], + install: { npmSpec: "@openclaw/whatsapp" }, + }, + }); + + copyBundledPluginMetadataWithEnv({ repoRoot, env: {} }); + + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "whatsapp"))).toBe(true); + }); }); diff --git a/tsdown.config.ts b/tsdown.config.ts index a12bbfc4eb6..8d534c79eaa 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { defineConfig, type UserConfig } from "tsdown"; -import { shouldBuildBundledCluster } from "./scripts/lib/optional-bundled-clusters.mjs"; +import { listBundledPluginBuildEntries } from "./scripts/lib/bundled-plugin-build-entries.mjs"; import { buildPluginSdkEntrySources } from "./scripts/lib/plugin-sdk-entries.mjs"; type InputOptionsFactory = Extract, Function>; @@ -80,60 +80,6 @@ function nodeBuildConfig(config: UserConfig): UserConfig { }; } -function listBundledPluginBuildEntries(): Record { - const extensionsRoot = path.join(process.cwd(), "extensions"); - const entries: Record = {}; - - for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { - if (!dirent.isDirectory()) { - continue; - } - if (!shouldBuildBundledCluster(dirent.name, process.env)) { - continue; - } - - const pluginDir = path.join(extensionsRoot, dirent.name); - const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); - if (!fs.existsSync(manifestPath)) { - continue; - } - - const packageJsonPath = path.join(pluginDir, "package.json"); - let packageEntries: string[] = []; - if (fs.existsSync(packageJsonPath)) { - try { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - openclaw?: { extensions?: unknown; setupEntry?: unknown }; - }; - packageEntries = Array.isArray(packageJson.openclaw?.extensions) - ? packageJson.openclaw.extensions.filter( - (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, - ) - : []; - const setupEntry = - typeof packageJson.openclaw?.setupEntry === "string" && - packageJson.openclaw.setupEntry.trim().length > 0 - ? packageJson.openclaw.setupEntry - : undefined; - if (setupEntry) { - packageEntries = Array.from(new Set([...packageEntries, setupEntry])); - } - } catch { - packageEntries = []; - } - } - - const sourceEntries = packageEntries.length > 0 ? packageEntries : ["./index.ts"]; - for (const entry of sourceEntries) { - const normalizedEntry = entry.replace(/^\.\//, ""); - const entryKey = `extensions/${dirent.name}/${normalizedEntry.replace(/\.[^.]+$/u, "")}`; - entries[entryKey] = path.join("extensions", dirent.name, normalizedEntry); - } - } - - return entries; -} - const bundledPluginBuildEntries = listBundledPluginBuildEntries(); function buildBundledHookEntries(): Record {