mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
* fix(packaging): adopt plugins selected for custom distributions * test: close composed-plugin state before cleanup * test: align CI planner fixtures with canonical owners * test: export forced-exit assertion for activation fixtures * test: compare selected CI owner execution policy
161 lines
6 KiB
TypeScript
161 lines
6 KiB
TypeScript
// Composes explicitly selected source plugins into a custom core distribution.
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import {
|
|
composePackagePlugins,
|
|
type DistributionPackageManifest,
|
|
} from "../../src/infra/package-plugin-composition.ts";
|
|
import type { PackageManifest } from "../../src/plugins/package-manifest.types.ts";
|
|
import { NON_PACKAGED_BUNDLED_PLUGIN_DIRS } from "../../src/shared/non-packaged-plugin-dirs.ts";
|
|
import {
|
|
collectBundledPluginBuildEntries,
|
|
collectRootPackageExcludedExtensionDirs,
|
|
DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV,
|
|
} from "./bundled-plugin-build-entries.mjs";
|
|
import { assertRealOutputRoot } from "./output-root-guard.mjs";
|
|
import {
|
|
PACKAGE_DIST_CONTENT_INVENTORY_RELATIVE_PATH,
|
|
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
|
|
} from "./package-dist-inventory-contract.mts";
|
|
import { PACKAGE_LIFECYCLE_PENDING_RELATIVE_PATH } from "./package-lifecycle-marker.mjs";
|
|
|
|
type PackageJson = DistributionPackageManifest & Pick<PackageManifest, "openclaw">;
|
|
|
|
export function resolvePackageBundledPlugins(sourceDir: string, pluginIds: string[]) {
|
|
const ids = [...new Set(pluginIds)].toSorted();
|
|
if (ids.length === 0) {
|
|
return [];
|
|
}
|
|
const entries = collectBundledPluginBuildEntries({
|
|
cwd: sourceDir,
|
|
env: { [DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: ids.join(",") },
|
|
});
|
|
const excluded = collectRootPackageExcludedExtensionDirs({ cwd: sourceDir });
|
|
return ids.map((id) => {
|
|
const entry = entries.find((candidate) => candidate.id === id);
|
|
if (!entry?.hasPackageJson || !excluded.has(id) || NON_PACKAGED_BUNDLED_PLUGIN_DIRS.has(id)) {
|
|
throw new Error(
|
|
`--bundle-plugin requires a source plugin excluded from the core package: ${id}`,
|
|
);
|
|
}
|
|
return entry;
|
|
});
|
|
}
|
|
|
|
/** Called under the canonical packer's source lifecycle lock, before bundling workspace deps. */
|
|
export async function preparePackageBundledPlugins(
|
|
sourceDir: string,
|
|
pluginIds: string[],
|
|
onCleanupFailure: (error: Error) => void,
|
|
) {
|
|
const selected = resolvePackageBundledPlugins(sourceDir, pluginIds);
|
|
if (selected.length === 0) {
|
|
return async () => {};
|
|
}
|
|
assertRealOutputRoot(path.join(sourceDir, "dist"));
|
|
const packagePath = path.join(sourceDir, "package.json");
|
|
const original = await fs.readFile(packagePath, "utf8");
|
|
const sourcePackageJson = JSON.parse(original) as PackageJson;
|
|
const pluginPackages = new Map<string, PackageJson>();
|
|
for (const { id, sourceEntries } of selected) {
|
|
const sourcePackage = JSON.parse(
|
|
await fs.readFile(path.join(sourceDir, "extensions", id, "package.json"), "utf8"),
|
|
) as PackageJson;
|
|
const pluginRoot = path.join(sourceDir, "dist", "extensions", id);
|
|
const builtPackage = JSON.parse(
|
|
await fs.readFile(path.join(pluginRoot, "package.json"), "utf8"),
|
|
) as PackageJson;
|
|
const manifest = JSON.parse(
|
|
await fs.readFile(path.join(pluginRoot, "openclaw.plugin.json"), "utf8"),
|
|
) as { id: string };
|
|
if (
|
|
manifest.id !== id ||
|
|
(
|
|
[
|
|
"name",
|
|
"version",
|
|
"dependencies",
|
|
"optionalDependencies",
|
|
"peerDependencies",
|
|
"peerDependenciesMeta",
|
|
] as const
|
|
).some((key) => !isDeepStrictEqual(builtPackage[key], sourcePackage[key]))
|
|
) {
|
|
throw new Error(
|
|
`Built plugin ${id} does not match source metadata; rebuild before packaging`,
|
|
);
|
|
}
|
|
for (const entry of sourceEntries) {
|
|
await fs.access(path.join(pluginRoot, entry.replace(/\.[^.]+$/u, ".js")));
|
|
}
|
|
pluginPackages.set(`dist/extensions/${id}/package.json`, builtPackage);
|
|
}
|
|
const packageJson = composePackagePlugins(
|
|
sourcePackageJson,
|
|
selected.map(({ id, packageJson: pluginPackage }) => ({
|
|
id,
|
|
packageJson: pluginPackage as PackageJson,
|
|
})),
|
|
);
|
|
const snapshots = await Promise.all(
|
|
[
|
|
"package.json",
|
|
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
|
|
PACKAGE_DIST_CONTENT_INVENTORY_RELATIVE_PATH,
|
|
PACKAGE_LIFECYCLE_PENDING_RELATIVE_PATH,
|
|
...pluginPackages.keys(),
|
|
].map(async (relativePath) => {
|
|
const target = path.join(sourceDir, relativePath);
|
|
const bytes = await fs.readFile(target).catch((error: unknown) => {
|
|
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
|
|
throw error;
|
|
}
|
|
return null;
|
|
});
|
|
return { target, bytes };
|
|
}),
|
|
);
|
|
const cleanup = async (preparationFailure?: { cause: unknown }) => {
|
|
const results = await Promise.allSettled(
|
|
snapshots.map(({ target, bytes }) =>
|
|
bytes === null ? fs.rm(target, { force: true }) : fs.writeFile(target, bytes),
|
|
),
|
|
);
|
|
const failures = results.filter((result) => result.status === "rejected");
|
|
if (failures.length) {
|
|
const error = new AggregateError(
|
|
[
|
|
...(preparationFailure ? [preparationFailure.cause] : []),
|
|
...failures.map((result) => result.reason),
|
|
],
|
|
"Selected plugin package cleanup failed",
|
|
preparationFailure,
|
|
);
|
|
onCleanupFailure(error);
|
|
throw error;
|
|
}
|
|
};
|
|
try {
|
|
await fs.writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
// Explicit composition transfers ownership to this distribution. Without
|
|
// this fact, Doctor retains an older managed npm copy with the same version.
|
|
for (const [relativePath, pluginPackage] of pluginPackages) {
|
|
pluginPackage.openclaw = {
|
|
...pluginPackage.openclaw,
|
|
build: { ...pluginPackage.openclaw?.build, bundledDist: true },
|
|
};
|
|
await fs.writeFile(
|
|
path.join(sourceDir, relativePath),
|
|
`${JSON.stringify(pluginPackage, null, 2)}\n`,
|
|
);
|
|
}
|
|
// Inventory must see the custom manifest before pack, or postinstall would prune the plugin.
|
|
const { writePackageDistInventoryForPublish } = await import("./package-dist-inventory.ts");
|
|
await writePackageDistInventoryForPublish(sourceDir);
|
|
return cleanup;
|
|
} catch (error) {
|
|
await cleanup({ cause: error });
|
|
throw error;
|
|
}
|
|
}
|