mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
improve(plugins): compile externalized plugins in source builds (#124639)
* build(plugins): compile externalized plugins into local dist * test(plugins): assert native external plugin loading * chore(plugins): keep source runner asset scan unchanged * refactor(plugins): isolate external local dist builds * test(plugins): codify external artifact precedence * test(plugins): preserve contract path boundary
This commit is contained in:
committed by
GitHub
parent
146350f417
commit
eeffa53b20
@@ -230,10 +230,11 @@ OPENCLAW_DOCKER_BUILD_NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_DOCKER_BUI
|
||||
`OPENCLAW_EXTENSIONS` selects plugin manifest ids from the source checkout;
|
||||
existing source-directory names are also accepted when they differ. The Docker
|
||||
build resolves the selection to source directories once, installs production
|
||||
dependencies, and, when a selected plugin is published separately with
|
||||
`openclaw.build.bundledDist: false`, compiles its runtime into the root bundled
|
||||
dist. This Docker-only packaging does not change the plugin's npm or ClawHub
|
||||
artifact contract. Unknown, invalid, or ambiguous ids fail the image build.
|
||||
dependencies, and includes the selected plugin runtime in the image. Source
|
||||
checkouts also compile first-party plugins published separately with
|
||||
`openclaw.build.bundledDist: false`; that marker still preserves the plugin's
|
||||
external npm or ClawHub ownership and does not change either artifact contract.
|
||||
Unknown, invalid, or ambiguous ids fail the image build.
|
||||
Known dependency/source-only ids keep their existing source and dependency
|
||||
staging without gaining a compiled root dist entry. A selected plugin with
|
||||
unified build entries must compile successfully; unselected external plugin
|
||||
|
||||
@@ -235,6 +235,7 @@ export const BUILD_ALL_STEPS: BuildAllStep[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
tsxStep("external-plugins:local-dist", "scripts/build-external-plugin-local-dist.mts"),
|
||||
tsxStep("check-cli-bootstrap-imports", "scripts/check-cli-bootstrap-imports.mts"),
|
||||
{
|
||||
label: "plugins:assets:copy",
|
||||
@@ -289,6 +290,7 @@ export const BUILD_ALL_PROFILES: Record<string, string[]> = {
|
||||
"tsdown-ai",
|
||||
"tsdown-packages",
|
||||
"tsdown-unified",
|
||||
"external-plugins:local-dist",
|
||||
"check-cli-bootstrap-imports",
|
||||
"plugins:assets:copy",
|
||||
"runtime-postbuild",
|
||||
@@ -304,6 +306,7 @@ export const BUILD_ALL_PROFILES: Record<string, string[]> = {
|
||||
ciArtifacts: [
|
||||
"plugins:assets:build",
|
||||
"tsdown",
|
||||
"external-plugins:local-dist",
|
||||
"check-cli-bootstrap-imports",
|
||||
"plugins:assets:copy",
|
||||
"runtime-postbuild",
|
||||
@@ -318,6 +321,7 @@ export const BUILD_ALL_PROFILES: Record<string, string[]> = {
|
||||
],
|
||||
gatewayWatch: [
|
||||
"tsdown",
|
||||
"external-plugins:local-dist",
|
||||
"check-cli-bootstrap-imports",
|
||||
"runtime-postbuild",
|
||||
"build-stamp",
|
||||
@@ -326,6 +330,7 @@ export const BUILD_ALL_PROFILES: Record<string, string[]> = {
|
||||
qaRuntime: [
|
||||
"plugins:assets:build",
|
||||
"tsdown",
|
||||
"external-plugins:local-dist",
|
||||
"check-cli-bootstrap-imports",
|
||||
"plugins:assets:copy",
|
||||
"runtime-postbuild",
|
||||
@@ -335,6 +340,7 @@ export const BUILD_ALL_PROFILES: Record<string, string[]> = {
|
||||
sourcePerformance: [
|
||||
"plugins:assets:build",
|
||||
"tsdown",
|
||||
"external-plugins:local-dist",
|
||||
"check-cli-bootstrap-imports",
|
||||
"plugins:assets:copy",
|
||||
"runtime-postbuild",
|
||||
@@ -345,6 +351,7 @@ export const BUILD_ALL_PROFILES: Record<string, string[]> = {
|
||||
],
|
||||
cliStartup: [
|
||||
"tsdown",
|
||||
"external-plugins:local-dist",
|
||||
"check-cli-bootstrap-imports",
|
||||
"runtime-postbuild",
|
||||
"build-stamp",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds source-checkout runtime output for externally published first-party plugins.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV } from "./lib/bundled-plugin-build-entries.mjs";
|
||||
import { shouldBuildBundledCluster } from "./lib/optional-bundled-clusters.mjs";
|
||||
import { assertRealOutputRoot } from "./lib/output-root-guard.mjs";
|
||||
import {
|
||||
buildPluginNpmRuntime,
|
||||
listPublishablePluginPackageDirs,
|
||||
type PluginPackageJson,
|
||||
} from "./lib/plugin-npm-runtime-build.mts";
|
||||
|
||||
type ExternalPluginLocalDistParams = {
|
||||
repoRoot?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
logLevel?: "silent" | "warn";
|
||||
};
|
||||
|
||||
function readPluginPackageJson(repoRoot: string, packageDir: string): PluginPackageJson {
|
||||
return JSON.parse(fs.readFileSync(path.join(repoRoot, packageDir, "package.json"), "utf8"));
|
||||
}
|
||||
|
||||
/** Lists external first-party packages that need source-checkout dist output. */
|
||||
export function listExternalPluginLocalDistPackageDirs(
|
||||
params: Pick<ExternalPluginLocalDistParams, "repoRoot" | "env"> = {},
|
||||
): string[] {
|
||||
const repoRoot = path.resolve(params.repoRoot ?? ".");
|
||||
const env = params.env ?? process.env;
|
||||
if (env[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]?.trim()) {
|
||||
return [];
|
||||
}
|
||||
return listPublishablePluginPackageDirs({ repoRoot }).filter((packageDir) => {
|
||||
const packageJson = readPluginPackageJson(repoRoot, packageDir);
|
||||
return (
|
||||
packageJson.openclaw?.build?.bundledDist === false &&
|
||||
shouldBuildBundledCluster(path.basename(packageDir), env, { packageJson })
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Builds isolated plugin graphs, then stages every output below its excluded root dist path. */
|
||||
export async function buildExternalPluginLocalDist(
|
||||
params: ExternalPluginLocalDistParams = {},
|
||||
): Promise<{ durationMs: number; pluginDirs: string[] }> {
|
||||
const repoRoot = path.resolve(params.repoRoot ?? ".");
|
||||
const packageDirs = listExternalPluginLocalDistPackageDirs({
|
||||
repoRoot,
|
||||
env: params.env,
|
||||
});
|
||||
const startedAt = performance.now();
|
||||
const pluginDirs: string[] = [];
|
||||
|
||||
for (const packageDir of packageDirs) {
|
||||
const result = await buildPluginNpmRuntime({
|
||||
repoRoot,
|
||||
packageDir,
|
||||
// Standalone package validation owns its existing bundler warnings; this
|
||||
// root build still surfaces errors and validates every emitted host import.
|
||||
logLevel: params.logLevel ?? "error",
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error(`${packageDir} did not produce source-checkout runtime output`);
|
||||
}
|
||||
const targetDir = path.join(repoRoot, "dist", "extensions", result.pluginDir);
|
||||
assertRealOutputRoot(targetDir);
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
|
||||
fs.cpSync(result.outDir, targetDir, { recursive: true });
|
||||
fs.rmSync(result.outDir, { recursive: true, force: true });
|
||||
pluginDirs.push(result.pluginDir);
|
||||
}
|
||||
|
||||
return {
|
||||
durationMs: performance.now() - startedAt,
|
||||
pluginDirs,
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
try {
|
||||
const result = await buildExternalPluginLocalDist();
|
||||
console.log(
|
||||
`[external-plugin-local-dist] built ${result.pluginDirs.length} plugins in ${Math.round(result.durationMs)}ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -290,7 +290,15 @@ export function copyBundledPluginMetadata(params: CopyMetadataParams = {}): void
|
||||
: undefined;
|
||||
const packageJson = isRecord(parsedPackageJson) ? parsedPackageJson : undefined;
|
||||
const topLevelPublicSurfaceEntries = collectTopLevelPublicSurfaceEntries(pluginDir);
|
||||
if (!shouldCopyBundledPluginMetadata(dirent.name, env, buildablePluginDirs)) {
|
||||
const hasExternalLocalDist =
|
||||
isRecord(packageJson?.openclaw) &&
|
||||
isRecord(packageJson.openclaw.build) &&
|
||||
packageJson.openclaw.build.bundledDist === false &&
|
||||
fs.existsSync(distPluginDir);
|
||||
if (
|
||||
!hasExternalLocalDist &&
|
||||
!shouldCopyBundledPluginMetadata(dirent.name, env, buildablePluginDirs)
|
||||
) {
|
||||
removePathIfExists(distPluginDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export type PluginPackageJson = JsonRecord & {
|
||||
dependencies?: JsonRecord;
|
||||
openclaw?: {
|
||||
assetScripts?: { build?: unknown };
|
||||
build?: { openclawVersion?: unknown; runtimeFormat?: unknown };
|
||||
build?: { bundledDist?: unknown; openclawVersion?: unknown; runtimeFormat?: unknown };
|
||||
compat?: { pluginApi?: unknown };
|
||||
release?: {
|
||||
bundleRuntimeDependencies?: unknown;
|
||||
|
||||
@@ -14,12 +14,18 @@ const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
const smokeEntryPath = path.join(repoRoot, "dist", "plugins", "build-smoke-entry.js");
|
||||
assert.ok(fs.existsSync(smokeEntryPath), `missing build output: ${smokeEntryPath}`);
|
||||
|
||||
const { clearPluginCommands, getPluginCommandSpecs, loadOpenClawPlugins, matchPluginCommand } =
|
||||
await import(pathToFileURL(smokeEntryPath).href);
|
||||
const {
|
||||
clearPluginCommands,
|
||||
getPluginCommandSpecs,
|
||||
getPluginModuleLoaderStats,
|
||||
loadOpenClawPlugins,
|
||||
matchPluginCommand,
|
||||
} = await import(pathToFileURL(smokeEntryPath).href);
|
||||
|
||||
assert.equal(typeof loadOpenClawPlugins, "function", "built loader export missing");
|
||||
assert.equal(typeof clearPluginCommands, "function", "clearPluginCommands missing");
|
||||
assert.equal(typeof getPluginCommandSpecs, "function", "getPluginCommandSpecs missing");
|
||||
assert.equal(typeof getPluginModuleLoaderStats, "function", "plugin loader stats missing");
|
||||
assert.equal(typeof matchPluginCommand, "function", "matchPluginCommand missing");
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-smoke-"));
|
||||
@@ -105,6 +111,12 @@ stageBundledPluginRuntime({ repoRoot });
|
||||
|
||||
const runtimeEntryPath = path.join(runtimePluginDir, "index.js");
|
||||
assert.ok(fs.existsSync(runtimeEntryPath), "runtime overlay entry missing");
|
||||
const smsRuntimeEntryPath = path.join(repoRoot, "dist-runtime", "extensions", "sms", "index.js");
|
||||
assert.ok(fs.existsSync(smsRuntimeEntryPath), "compiled SMS runtime entry missing");
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(repoRoot, "dist-runtime", "extensions", "mxc", "mxc-spawn-launcher.mjs")),
|
||||
"compiled MXC runtime asset missing",
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(repoRoot, "dist-runtime", "plugins", "commands.js")),
|
||||
false,
|
||||
@@ -113,6 +125,54 @@ assert.equal(
|
||||
|
||||
clearPluginCommands();
|
||||
|
||||
const smsStatsBefore = getPluginModuleLoaderStats();
|
||||
const smsRegistry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
preferBuiltPluginArtifacts: true,
|
||||
workspaceDir: tempRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "dist-runtime", "extensions"),
|
||||
},
|
||||
config: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: ["sms"],
|
||||
entries: {
|
||||
sms: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const smsRecord = smsRegistry.plugins.find((entry: { id: string }) => entry.id === "sms");
|
||||
assert.ok(smsRecord, "SMS plugin missing from registry");
|
||||
assert.equal(smsRecord.status, "loaded", smsRecord.error ?? "SMS plugin failed to load");
|
||||
const smsStatsAfter = getPluginModuleLoaderStats();
|
||||
assert.ok(
|
||||
smsStatsAfter.nativeHits > smsStatsBefore.nativeHits,
|
||||
"compiled SMS runtime did not use native loading",
|
||||
);
|
||||
for (const counter of [
|
||||
"nativeMisses",
|
||||
"sourceTransformForced",
|
||||
"sourceTransformFallbacks",
|
||||
] as const) {
|
||||
assert.equal(
|
||||
smsStatsAfter[counter],
|
||||
smsStatsBefore[counter],
|
||||
`compiled SMS runtime changed ${counter}`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
smsStatsAfter.topSourceTransformTargets.some(({ target }: { target: string }) =>
|
||||
target.replaceAll("\\", "/").includes("/extensions/sms/"),
|
||||
),
|
||||
false,
|
||||
"compiled SMS runtime reached the source transformer",
|
||||
);
|
||||
|
||||
clearPluginCommands();
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
workspaceDir: tempRoot,
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
export { clearPluginCommands, executePluginCommand, matchPluginCommand } from "./commands.js";
|
||||
export { getPluginCommandSpecs } from "./command-specs.js";
|
||||
export { loadOpenClawPlugins, loadPluginRegistryHandle } from "./loader.js";
|
||||
export { getPluginModuleLoaderStats } from "./plugin-module-loader-cache.js";
|
||||
|
||||
@@ -445,6 +445,28 @@ describe("copyBundledPluginMetadata", () => {
|
||||
expect(fs.existsSync(staleDistDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves isolated source-checkout output for an external plugin", () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-external-plugin-local-dist-meta-");
|
||||
createPlugin(repoRoot, {
|
||||
id: "sms",
|
||||
packageName: "@openclaw/sms",
|
||||
packageOpenClaw: {
|
||||
extensions: ["./index.ts"],
|
||||
build: { bundledDist: false },
|
||||
release: { publishToNpm: true },
|
||||
},
|
||||
});
|
||||
const distPluginDir = path.join(repoRoot, "dist", "extensions", "sms");
|
||||
fs.mkdirSync(distPluginDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {};\n", "utf8");
|
||||
|
||||
copyBundledPluginMetadata({ repoRoot });
|
||||
|
||||
expect(fs.existsSync(path.join(distPluginDir, "index.js"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(distPluginDir, "openclaw.plugin.json"))).toBe(true);
|
||||
expect(readBundledPackageJson(repoRoot, "sms").openclaw?.extensions).toEqual(["./index.js"]);
|
||||
});
|
||||
|
||||
it("preserves manifest-less runtime support package outputs and copies package metadata", () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-bundled-runtime-support-");
|
||||
const pluginDir = path.join(repoRoot, "extensions", "image-generation-core");
|
||||
|
||||
@@ -200,6 +200,75 @@ function loadBuiltArtifactScenario(scenario: BuiltArtifactScenario) {
|
||||
return registry.plugins.find((entry) => entry.id === scenario.id)?.status;
|
||||
}
|
||||
|
||||
function loadSourceExternalArtifactScenario(params: {
|
||||
sourceBody: string;
|
||||
packageLocalBody: string;
|
||||
rootBuildBody?: string;
|
||||
runtimeOverlayBody?: string;
|
||||
}) {
|
||||
const id = "source-external-artifact-test";
|
||||
const repoRoot = makePluginLoaderTempDir();
|
||||
const sourceDir = path.join(repoRoot, "extensions", id);
|
||||
const rootBuildDir = path.join(repoRoot, "dist", "extensions", id);
|
||||
mkdirSafe(path.join(repoRoot, ".git"));
|
||||
mkdirSafe(path.join(repoRoot, "src"));
|
||||
writeFixtureText(repoRoot, "pnpm-workspace.yaml", "packages: []\n");
|
||||
writeFixtureJson(sourceDir, "openclaw.plugin.json", pluginManifest(id));
|
||||
writeFixtureJson(sourceDir, "package.json", {
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
build: { bundledDist: false },
|
||||
},
|
||||
});
|
||||
writeFixtureText(sourceDir, "index.ts", params.sourceBody);
|
||||
writeFixtureText(sourceDir, "dist/index.js", params.packageLocalBody);
|
||||
if (params.rootBuildBody) {
|
||||
mkdirSafe(rootBuildDir);
|
||||
fs.copyFileSync(
|
||||
path.join(sourceDir, "openclaw.plugin.json"),
|
||||
path.join(rootBuildDir, "openclaw.plugin.json"),
|
||||
);
|
||||
writeFixtureJson(rootBuildDir, "package.json", {
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
});
|
||||
writeFixtureText(rootBuildDir, "index.js", params.rootBuildBody);
|
||||
}
|
||||
if (params.runtimeOverlayBody) {
|
||||
writeFixtureText(
|
||||
repoRoot,
|
||||
path.join("dist-runtime", "extensions", id, "index.js"),
|
||||
params.runtimeOverlayBody,
|
||||
);
|
||||
}
|
||||
|
||||
const config = {
|
||||
plugins: {
|
||||
allow: [id],
|
||||
entries: { [id]: { enabled: true } },
|
||||
},
|
||||
};
|
||||
const registry = withEnv(
|
||||
{
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: params.rootBuildBody
|
||||
? path.join(repoRoot, "dist", "extensions")
|
||||
: path.join(repoRoot, "extensions"),
|
||||
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
|
||||
},
|
||||
() => {
|
||||
const manifestRegistry = loadPluginManifestRegistryCore({ config });
|
||||
return loadOpenClawPlugins({
|
||||
cache: false,
|
||||
preferBuiltPluginArtifacts: true,
|
||||
onlyPluginIds: [id],
|
||||
config,
|
||||
manifestRegistry,
|
||||
});
|
||||
},
|
||||
);
|
||||
return registry.plugins.find((entry) => entry.id === id)?.status;
|
||||
}
|
||||
|
||||
describe("loadOpenClawPlugins", () => {
|
||||
it("setup-loads a trusted global channel plugin when the caller scopes to it", () => {
|
||||
useNoBundledPlugins();
|
||||
@@ -863,80 +932,25 @@ ${channelPluginSource({
|
||||
).toBe("loaded");
|
||||
});
|
||||
|
||||
it("ignores built artifacts when the bundled source plugin opts out of core dist", () => {
|
||||
const repoRoot = makePluginLoaderTempDir();
|
||||
const sourceDir = path.join(repoRoot, "extensions", "source-only-artifact-test");
|
||||
const builtPluginDir = path.join(repoRoot, "dist", "extensions", "source-only-artifact-test");
|
||||
mkdirSafe(path.join(repoRoot, ".git"));
|
||||
mkdirSafe(path.join(repoRoot, "src"));
|
||||
writeFixtureText(repoRoot, "pnpm-workspace.yaml", "packages: []\n");
|
||||
writeFixtureJson(
|
||||
sourceDir,
|
||||
"openclaw.plugin.json",
|
||||
pluginManifest("source-only-artifact-test"),
|
||||
);
|
||||
writeFixtureJson(sourceDir, "package.json", {
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
build: { bundledDist: false },
|
||||
},
|
||||
});
|
||||
writeFixtureText(
|
||||
sourceDir,
|
||||
"index.ts",
|
||||
'export default { id: "source-only-artifact-test", register() {} };\n',
|
||||
);
|
||||
writeFixtureText(
|
||||
sourceDir,
|
||||
"dist/index.js",
|
||||
'throw new Error("stale package-local dist should not load");\n',
|
||||
);
|
||||
mkdirSafe(builtPluginDir);
|
||||
fs.copyFileSync(
|
||||
path.join(sourceDir, "openclaw.plugin.json"),
|
||||
path.join(builtPluginDir, "openclaw.plugin.json"),
|
||||
);
|
||||
writeFixtureJson(builtPluginDir, "package.json", {
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
});
|
||||
writeFixtureText(
|
||||
builtPluginDir,
|
||||
"index.js",
|
||||
'throw new Error("stale discovered core dist should not load");\n',
|
||||
);
|
||||
writeFixtureText(
|
||||
repoRoot,
|
||||
"dist-runtime/extensions/source-only-artifact-test/index.js",
|
||||
'throw new Error("stale core dist should not load");\n',
|
||||
);
|
||||
it("ignores package-local dist when a bundled source plugin opts out of core dist", () => {
|
||||
expect(
|
||||
loadSourceExternalArtifactScenario({
|
||||
sourceBody: 'export default { id: "source-external-artifact-test", register() {} };\n',
|
||||
packageLocalBody: 'throw new Error("stale package-local dist should not load");\n',
|
||||
}),
|
||||
).toBe("loaded");
|
||||
});
|
||||
|
||||
const config = {
|
||||
plugins: {
|
||||
allow: ["source-only-artifact-test"],
|
||||
entries: { "source-only-artifact-test": { enabled: true } },
|
||||
},
|
||||
};
|
||||
const registry = withEnv(
|
||||
{
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "dist", "extensions"),
|
||||
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
|
||||
},
|
||||
() => {
|
||||
const manifestRegistry = loadPluginManifestRegistryCore({ config });
|
||||
return loadOpenClawPlugins({
|
||||
cache: false,
|
||||
preferBuiltPluginArtifacts: true,
|
||||
onlyPluginIds: ["source-only-artifact-test"],
|
||||
config,
|
||||
manifestRegistry,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
expect(registry.plugins.find((entry) => entry.id === "source-only-artifact-test")?.status).toBe(
|
||||
"loaded",
|
||||
);
|
||||
it("prefers the root build when a bundled source plugin opts out of core dist", () => {
|
||||
expect(
|
||||
loadSourceExternalArtifactScenario({
|
||||
sourceBody: 'throw new Error("source should not load when root build exists");\n',
|
||||
packageLocalBody: 'throw new Error("stale package-local dist should not load");\n',
|
||||
rootBuildBody: 'module.exports = { id: "source-external-artifact-test", register() {} };\n',
|
||||
runtimeOverlayBody:
|
||||
'throw new Error("staged runtime should canonicalize to the root build");\n',
|
||||
}),
|
||||
).toBe("loaded");
|
||||
});
|
||||
|
||||
it("prefers package-local dist artifacts over workspace source TS when requested", () => {
|
||||
|
||||
@@ -87,6 +87,26 @@ describe("resolvePluginRuntimeArtifact", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("prefers the root build for source-external plugins without using package-local output", () => {
|
||||
const fixture = createBundledPluginFixture();
|
||||
const packageLocalSource = path.join(fixture.rootDir, "dist", "index.js");
|
||||
fs.mkdirSync(path.dirname(packageLocalSource), { recursive: true });
|
||||
fs.writeFileSync(packageLocalSource, 'module.exports = { id: "stale" };\n');
|
||||
|
||||
const resolved = resolvePluginRuntimeArtifact({
|
||||
pluginId: "fixture",
|
||||
entryKind: "runtime",
|
||||
rootDir: fixture.rootDir,
|
||||
source: fixture.source,
|
||||
origin: "bundled",
|
||||
preferBuiltPluginArtifacts: true,
|
||||
packageManifest: { build: { bundledDist: false } },
|
||||
});
|
||||
|
||||
expect(resolved.source).toBe(fixture.builtSource);
|
||||
expect(resolved.source).not.toBe(fs.realpathSync(packageLocalSource));
|
||||
});
|
||||
|
||||
it("aliases different physical inputs for the same logical runtime entry", () => {
|
||||
const fixture = createBundledPluginFixture();
|
||||
const first = resolveFixture({
|
||||
|
||||
@@ -117,10 +117,13 @@ function resolvePreferredBuiltRuntimeArtifact(params: {
|
||||
}
|
||||
return { source, rootDir };
|
||||
}
|
||||
if (params.packageManifest?.build?.bundledDist === false) {
|
||||
return { source, rootDir };
|
||||
}
|
||||
const packageLocalArtifactSource = resolvePackageLocalDistRuntimeArtifact({ source, rootDir });
|
||||
// Source-external plugins can leave package-local npm build output behind.
|
||||
// Keep source authoritative over that output, but allow the lifecycle-owned root
|
||||
// build to provide the fresh JavaScript artifact used by source checkouts.
|
||||
const packageLocalArtifactSource =
|
||||
params.packageManifest?.build?.bundledDist === false
|
||||
? null
|
||||
: resolvePackageLocalDistRuntimeArtifact({ source, rootDir });
|
||||
if (packageLocalArtifactSource) {
|
||||
return { source: packageLocalArtifactSource, rootDir };
|
||||
}
|
||||
@@ -137,6 +140,8 @@ function resolvePreferredBuiltRuntimeArtifact(params: {
|
||||
return { source, rootDir };
|
||||
}
|
||||
const artifactRelativePath = rewriteBundledRuntimeArtifactRelativePath(relativeSource);
|
||||
// The runtime overlay is a staging fallback. Final canonicalization maps it
|
||||
// to the matching root build when both exist, so one build owns execution.
|
||||
for (const artifactRootName of ["dist-runtime", "dist"] as const) {
|
||||
const artifactRoot = path.join(
|
||||
packageRoot,
|
||||
|
||||
@@ -362,6 +362,7 @@ describe("resolveBuildAllSteps", () => {
|
||||
"tsdown-ai",
|
||||
"tsdown-packages",
|
||||
"tsdown-unified",
|
||||
"external-plugins:local-dist",
|
||||
"check-cli-bootstrap-imports",
|
||||
"plugins:assets:copy",
|
||||
"runtime-postbuild",
|
||||
@@ -436,6 +437,7 @@ describe("resolveBuildAllSteps", () => {
|
||||
expect(resolveBuildAllSteps("ciArtifacts").map((step) => step.label)).toEqual([
|
||||
"plugins:assets:build",
|
||||
"tsdown",
|
||||
"external-plugins:local-dist",
|
||||
"check-cli-bootstrap-imports",
|
||||
"plugins:assets:copy",
|
||||
"runtime-postbuild",
|
||||
@@ -568,6 +570,7 @@ describe("resolveBuildAllSteps", () => {
|
||||
it("uses a minimal built runtime profile for gateway watch regression", () => {
|
||||
expect(resolveBuildAllSteps("gatewayWatch").map((step) => step.label)).toEqual([
|
||||
"tsdown",
|
||||
"external-plugins:local-dist",
|
||||
"check-cli-bootstrap-imports",
|
||||
"runtime-postbuild",
|
||||
"build-stamp",
|
||||
@@ -579,6 +582,7 @@ describe("resolveBuildAllSteps", () => {
|
||||
expect(resolveBuildAllSteps("qaRuntime").map((step) => step.label)).toEqual([
|
||||
"plugins:assets:build",
|
||||
"tsdown",
|
||||
"external-plugins:local-dist",
|
||||
"check-cli-bootstrap-imports",
|
||||
"plugins:assets:copy",
|
||||
"runtime-postbuild",
|
||||
@@ -591,6 +595,7 @@ describe("resolveBuildAllSteps", () => {
|
||||
expect(resolveBuildAllSteps("sourcePerformance").map((step) => step.label)).toEqual([
|
||||
"plugins:assets:build",
|
||||
"tsdown",
|
||||
"external-plugins:local-dist",
|
||||
"check-cli-bootstrap-imports",
|
||||
"plugins:assets:copy",
|
||||
"runtime-postbuild",
|
||||
@@ -604,6 +609,7 @@ describe("resolveBuildAllSteps", () => {
|
||||
it("uses a CLI startup profile without generated plugin assets", () => {
|
||||
expect(resolveBuildAllSteps("cliStartup").map((step) => step.label)).toEqual([
|
||||
"tsdown",
|
||||
"external-plugins:local-dist",
|
||||
"check-cli-bootstrap-imports",
|
||||
"runtime-postbuild",
|
||||
"build-stamp",
|
||||
@@ -676,6 +682,19 @@ describe("resolveBuildAllSteps", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("builds isolated external plugin output after tsdown and before runtime postbuild", () => {
|
||||
for (const profile of Object.keys(BUILD_ALL_PROFILES)) {
|
||||
const labels = resolveBuildAllSteps(profile).map((step) => step.label);
|
||||
const lastTsdown = profile === "full" ? "tsdown-unified" : "tsdown";
|
||||
expect(labels.indexOf("external-plugins:local-dist")).toBeGreaterThan(
|
||||
labels.indexOf(lastTsdown),
|
||||
);
|
||||
expect(labels.indexOf("external-plugins:local-dist")).toBeLessThan(
|
||||
labels.indexOf("runtime-postbuild"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("writes the runtime postbuild stamp after the build stamp", () => {
|
||||
const labels = resolveBuildAllSteps("full").map((step) => step.label);
|
||||
expect(labels).toContain("runtime-postbuild");
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildExternalPluginLocalDist,
|
||||
listExternalPluginLocalDistPackageDirs,
|
||||
} from "../../scripts/build-external-plugin-local-dist.mts";
|
||||
import {
|
||||
collectRootPackageExcludedExtensionDirs,
|
||||
DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV,
|
||||
} from "../../scripts/lib/bundled-plugin-build-entries.mjs";
|
||||
|
||||
describe("external plugin local dist build", () => {
|
||||
it("selects every externalized first-party plugin behind a package exclusion", () => {
|
||||
const packageDirs = listExternalPluginLocalDistPackageDirs();
|
||||
const excludedPluginIds = collectRootPackageExcludedExtensionDirs();
|
||||
|
||||
expect(packageDirs).toHaveLength(61);
|
||||
expect(packageDirs).toEqual(
|
||||
expect.arrayContaining(["extensions/slack", "extensions/sms", "extensions/mxc"]),
|
||||
);
|
||||
expect(packageDirs).not.toContain("extensions/whatsapp");
|
||||
expect(
|
||||
packageDirs.every((packageDir) => excludedPluginIds.has(packageDir.split("/").at(-1) ?? "")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves Docker-selected external plugin compilation on the unified build path", () => {
|
||||
expect(
|
||||
listExternalPluginLocalDistPackageDirs({
|
||||
env: {
|
||||
...process.env,
|
||||
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "slack",
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("performs no writes when Docker owns the selected build", async () => {
|
||||
await expect(
|
||||
buildExternalPluginLocalDist({
|
||||
env: {
|
||||
...process.env,
|
||||
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "slack",
|
||||
},
|
||||
logLevel: "silent",
|
||||
}),
|
||||
).resolves.toMatchObject({ pluginDirs: [] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user