openclaw/scripts/lib/package-bundled-plugins.mts
Ayaan Zaidi ae3aff5c28
fix(update): complete package lifecycle outside dist inventory
## Problem

A pnpm 11 global update from OpenClaw 2026.7.1-2 to 2026.8.1 fails verification and rolls back. The 2026.8.1 package puts `openclaw-install-guard` inside `dist/`, but the old updater treats every uninventoried `dist/` file as invalid.

## Root cause

Package lifecycle state was stored inside the closed `dist/` inventory. That made a valid pending lifecycle marker look like package corruption to an older updater.

## Fix

- Store pending lifecycle state at package root as `.openclaw-lifecycle-pending`.
- Let postinstall remove the marker only after all lifecycle work succeeds.
- Use one lifecycle completion owner from the updater, `dist/index.js`, and `openclaw.mjs`.
- Keep the lifecycle lock valid beyond the full preinstall and postinstall timeout budget.
- Keep temporary recovery support for the 2026.8.1 `dist/openclaw-install-guard` path.
- Keep source package preparation and worker package generation aligned with the new marker contract.

## Product proof

- Red: a published 2026.7.1-2 pnpm 11 install rejected the published 2026.8.1 package with `unexpected packaged dist file dist/openclaw-install-guard`, exited nonzero, and remained on 2026.7.1-2.
- Green: the built candidate passes the old-updater upgrade path, the pnpm 11 lifecycle-repair path, a forced postinstall failure and retry, and native npm controls.
- Anti-cheat: the proof checks the installed CLI version before and after the update.

## Validation

- `node scripts/run-vitest.mjs src/infra/package-lifecycle.test.ts src/infra/package-update-steps.pnpm11-guard.test.ts src/index.entrypoint.test.ts`
- Focused lifecycle, updater, tarball, postinstall, inventory, and entrypoint suites: 176 tests passed.
- Exact-head lifecycle lock suite: 4 tests passed, including the old 20-minute expiry boundary.
- Remote core and scripts checks passed.
- `git diff --check`
- GitHub CI is the full release and platform gate.

## Scope

- Production and release-tooling delta: +370/-162, net +208.
- Test and CI support delta: +370/-84, net +286.
- The production growth adds the shared lifecycle owner, crash-safe retry marker, and concurrent-launch lock. It removes the updater-only lifecycle sequence and keeps the closed `dist/` verifier unchanged.
- Sibling coverage: updater, package launcher, legacy package entrypoint, installers, tarball validator, worker bootstrap package, and Docker package preparation.

## ClawSweeper

- No actionable code findings.
- Rank-up skip: the package-upgrade trace came from an internal isolated runner and is not suitable for a public log attachment. Exact-head GitHub CI and the focused regression commands above remain the public proof.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-08-31 15:12:42 +05:30

135 lines
5 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 {
collectBundledPluginBuildEntries,
collectRootPackageExcludedExtensionDirs,
DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV,
NON_PACKAGED_BUNDLED_PLUGIN_DIRS,
} from "./bundled-plugin-build-entries.mjs";
import { assertRealOutputRoot } from "./output-root-guard.mjs";
import { 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;
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[]) {
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;
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")));
}
}
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_LIFECYCLE_PENDING_RELATIVE_PATH,
].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) {
throw new AggregateError(
[
...(preparationFailure ? [preparationFailure.cause] : []),
...failures.map((result) => result.reason),
],
"Selected plugin package cleanup failed",
preparationFailure,
);
}
};
try {
await fs.writeFile(packagePath, `${JSON.stringify(packageJson, 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;
}
}