mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
964c8c84c1
* refactor: consolidate coercion ownership Centralize four canonical coercion helpers, migrate exact core and plugin duplicates through narrow Plugin SDK facades, and enforce declaration and plugin-normalization ownership boundaries. The sweep adds eight focused SDK exports while deleting more production and tooling code than it adds. User-visible behavior is unchanged except for safer equivalent object and UI parsing at existing boundaries. * fix: guard integer option ownership Register resolveIntegerOption with the canonical function owner and extend the declaration-guard fixture so future local duplicates fail validation. * fix: keep integer helpers on numeric facade Remove the unshipped duplicate string-coerce exports and route every affected plugin consumer through the existing number-runtime contract. * fix: point numeric coercion to number runtime Make boundary and declaration diagnostics recommend the canonical numeric facade, with failing-before coverage for both guidance paths.
978 lines
32 KiB
TypeScript
978 lines
32 KiB
TypeScript
// Augments plugin npm package manifests with generated runtime/package metadata.
|
|
import { spawnSync } from "node:child_process";
|
|
import type { SpawnSyncOptions } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import JSON5 from "json5";
|
|
import {
|
|
generateNpmPackageLock,
|
|
packageJsonForNpmLock,
|
|
readNpmLockOverrides,
|
|
} from "../generate-npm-package-lock.mts";
|
|
import { resolveNpmRunner } from "../npm-runner.mts";
|
|
import type { NpmRunnerParams } from "../npm-runner.mts";
|
|
import {
|
|
listPluginNpmRuntimeBuildOutputs,
|
|
resolvePluginNpmRuntimeBuildPlan,
|
|
} from "./plugin-npm-runtime-build.mts";
|
|
import type { PluginNpmRuntimeBuildPlan, PluginPackageJson } from "./plugin-npm-runtime-build.mts";
|
|
import { isRecord } from "./record-shared.mjs";
|
|
|
|
const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA_PATH =
|
|
"src/config/bundled-channel-config-metadata.generated.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
type PluginPackageParams = Parameters<typeof resolvePluginNpmRuntimeBuildPlan>[0] & {
|
|
bundleDependencies?: unknown;
|
|
};
|
|
type GeneratedChannelConfig = {
|
|
description?: string;
|
|
label?: string;
|
|
schema: JsonRecord;
|
|
uiHints?: JsonRecord;
|
|
};
|
|
type GeneratedChannelConfigs = Record<string, GeneratedChannelConfig>;
|
|
type PluginPackageContext = Pick<
|
|
PluginNpmRuntimeBuildPlan,
|
|
"packageDir" | "packageJson" | "pluginDir"
|
|
>;
|
|
type SpawnResult = Pick<ReturnType<typeof spawnSync>, "error" | "status">;
|
|
type PluginSpawnOptions = SpawnSyncOptions;
|
|
type PluginNpmCommandParams = Omit<NpmRunnerParams, "npmArgs">;
|
|
type PluginNpmCommand = {
|
|
args: string[];
|
|
command: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
shell: boolean;
|
|
windowsVerbatimArguments?: boolean;
|
|
};
|
|
type PackageLockOptions = NonNullable<Parameters<typeof generateNpmPackageLock>[1]>;
|
|
type GeneratePackageLock = (packageDir: string, options: PackageLockOptions) => string;
|
|
|
|
function readJsonFile(filePath: string): PluginPackageJson {
|
|
return JSON.parse(fs.readFileSync(filePath, "utf8")) as PluginPackageJson;
|
|
}
|
|
|
|
function writeJsonFile(filePath: string, value: unknown) {
|
|
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function resolvePackageDir(repoRoot: string, packageDir: string) {
|
|
return path.isAbsolute(packageDir) ? packageDir : path.resolve(repoRoot, packageDir);
|
|
}
|
|
|
|
function resolvePackageJsonPath(packageDir: string) {
|
|
return path.join(packageDir, "package.json");
|
|
}
|
|
|
|
function packageRelativePathExists(packageDir: string, relativePath: string) {
|
|
return fs.existsSync(path.join(packageDir, relativePath));
|
|
}
|
|
|
|
function normalizePackPath(value: string) {
|
|
return value.trim().replaceAll("\\", "/").replace(/^\.\//u, "");
|
|
}
|
|
|
|
function escapeRegExp(value: string) {
|
|
return value.replace(/[|\\{}()[\]^$+?.]/gu, "\\$&");
|
|
}
|
|
|
|
function packFilePatternMatchesPath(pattern: string, relativePath: string) {
|
|
const normalizedPattern = normalizePackPath(pattern).replace(/^!/u, "");
|
|
const normalizedPath = normalizePackPath(relativePath);
|
|
if (!normalizedPattern || !normalizedPath) {
|
|
return false;
|
|
}
|
|
if (normalizedPattern === normalizedPath) {
|
|
return true;
|
|
}
|
|
|
|
let source = "";
|
|
for (let index = 0; index < normalizedPattern.length; index += 1) {
|
|
const char = normalizedPattern[index];
|
|
const next = normalizedPattern[index + 1];
|
|
const afterNext = normalizedPattern[index + 2];
|
|
if (char === "*" && next === "*" && afterNext === "/") {
|
|
source += "(?:.*/)?";
|
|
index += 2;
|
|
continue;
|
|
}
|
|
if (char === "*" && next === "*") {
|
|
source += ".*";
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (char === "*") {
|
|
source += "[^/]*";
|
|
continue;
|
|
}
|
|
source += escapeRegExp(char ?? "");
|
|
}
|
|
return new RegExp(`^${source}$`, "u").test(normalizedPath);
|
|
}
|
|
|
|
function assertPackageFilesDoNotExcludeRequiredRuntimeArtifacts(plan: PluginNpmRuntimeBuildPlan) {
|
|
const fileRules = Array.isArray(plan.packageJson.files)
|
|
? plan.packageJson.files.filter((entry) => typeof entry === "string")
|
|
: [];
|
|
const exclusions = fileRules.filter((entry) => normalizePackPath(entry).startsWith("!"));
|
|
if (exclusions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
for (const requiredPath of listPluginNpmRuntimeBuildOutputs(plan)) {
|
|
for (const exclusion of exclusions) {
|
|
if (packFilePatternMatchesPath(exclusion, requiredPath)) {
|
|
throw new Error(
|
|
`package file rule '${exclusion}' excludes required package-local runtime file '${requiredPath}' for ${plan.pluginDir}. Remove the negation or publish would advertise a missing runtime entry.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertPluginNpmRuntimeBuildExists(plan: PluginNpmRuntimeBuildPlan) {
|
|
const missing = listPluginNpmRuntimeBuildOutputs(plan).filter(
|
|
(runtimePath) => !packageRelativePathExists(plan.packageDir, runtimePath.replace(/^\.\//u, "")),
|
|
);
|
|
if (missing.length > 0) {
|
|
const packageName =
|
|
typeof plan.packageJson.name === "string" ? plan.packageJson.name : plan.pluginDir;
|
|
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 ${packageName}.`,
|
|
].join("\n"),
|
|
);
|
|
}
|
|
assertPackageFilesDoNotExcludeRequiredRuntimeArtifacts(plan);
|
|
}
|
|
|
|
function resolvePackagedChannelStateMetadata(
|
|
metadata: unknown,
|
|
metadataKey: string,
|
|
plan: PluginNpmRuntimeBuildPlan,
|
|
) {
|
|
if (
|
|
!metadata ||
|
|
!isRecord(metadata) ||
|
|
typeof metadata.specifier !== "string" ||
|
|
!metadata.specifier.trim()
|
|
) {
|
|
return metadata;
|
|
}
|
|
|
|
const normalizedSpecifier = normalizePackPath(metadata.specifier);
|
|
const sourceEntry = normalizedSpecifier.replace(/\.(?:[cm]?[jt]s)$/u, "");
|
|
const runtimeSpecifier = plan.runtimeBuildOutputs.find((runtimePath) => {
|
|
const normalizedRuntimePath = normalizePackPath(runtimePath);
|
|
return (
|
|
normalizedRuntimePath === normalizedSpecifier ||
|
|
normalizedRuntimePath.replace(/^dist\//u, "").replace(/\.(?:[cm]?js)$/u, "") === sourceEntry
|
|
);
|
|
});
|
|
if (!runtimeSpecifier) {
|
|
throw new Error(
|
|
`channel ${metadataKey} specifier '${metadata.specifier}' has no package-local runtime output for ${plan.pluginDir}`,
|
|
);
|
|
}
|
|
|
|
// Published plugins omit source files; installed channel probes must load
|
|
// the exact ESM or CommonJS sidecar emitted by the package runtime build.
|
|
return {
|
|
...metadata,
|
|
specifier: runtimeSpecifier,
|
|
};
|
|
}
|
|
|
|
function resolvePackagedChannelMetadata(plan: PluginNpmRuntimeBuildPlan) {
|
|
const channel = plan.packageJson.openclaw?.channel;
|
|
if (!isRecord(channel)) {
|
|
return channel;
|
|
}
|
|
|
|
const packagedChannel: JsonRecord = { ...channel };
|
|
for (const metadataKey of ["configuredState", "persistedAuthState"]) {
|
|
if (Object.hasOwn(channel, metadataKey)) {
|
|
packagedChannel[metadataKey] = resolvePackagedChannelStateMetadata(
|
|
channel[metadataKey],
|
|
metadataKey,
|
|
plan,
|
|
);
|
|
}
|
|
}
|
|
return packagedChannel;
|
|
}
|
|
|
|
function hasPackageRuntimeDependencies(packageJson: PluginPackageJson) {
|
|
return (
|
|
Object.keys(packageJson.dependencies ?? {}).length > 0 ||
|
|
Object.keys(packageJson.optionalDependencies ?? {}).length > 0
|
|
);
|
|
}
|
|
|
|
function listPackageRuntimeDependencyNames(packageJson: PluginPackageJson) {
|
|
return [
|
|
...Object.keys(packageJson.dependencies ?? {}),
|
|
...Object.keys(packageJson.optionalDependencies ?? {}),
|
|
].toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function listConfiguredBundledDependencyNames(packageJson: PluginPackageJson) {
|
|
if (Array.isArray(packageJson.bundledDependencies)) {
|
|
return packageJson.bundledDependencies.filter((name) => typeof name === "string");
|
|
}
|
|
if (Array.isArray(packageJson.bundleDependencies)) {
|
|
return packageJson.bundleDependencies.filter((name) => typeof name === "string");
|
|
}
|
|
if (packageJson.bundleDependencies === true) {
|
|
return listPackageRuntimeDependencyNames(packageJson);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Resolve an npm command invocation for plugin package scripts.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function resolvePluginNpmCommand(
|
|
args: string[],
|
|
params: PluginNpmCommandParams = {},
|
|
): PluginNpmCommand {
|
|
return resolveNpmRunner({
|
|
comSpec: params.comSpec,
|
|
env: params.env,
|
|
execPath: params.execPath,
|
|
existsSync: params.existsSync,
|
|
npmArgs: args,
|
|
platform: params.platform,
|
|
});
|
|
}
|
|
|
|
function spawnNpmSync(args: string[], options: SpawnSyncOptions = {}): SpawnResult {
|
|
const invocation = resolvePluginNpmCommand(args, { env: options.env ?? process.env });
|
|
return spawnSync(invocation.command, invocation.args, {
|
|
...options,
|
|
...(invocation.env ? { env: invocation.env } : {}),
|
|
...(invocation.shell !== undefined ? { shell: invocation.shell } : {}),
|
|
...(invocation.windowsVerbatimArguments !== undefined
|
|
? { windowsVerbatimArguments: invocation.windowsVerbatimArguments }
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
function spawnCommandSync(command: string, args: string[], options: SpawnSyncOptions): SpawnResult {
|
|
if (command === "npm") {
|
|
return spawnNpmSync(args, options);
|
|
}
|
|
return spawnSync(command, args, options);
|
|
}
|
|
|
|
/** @internal Directly tested release-script implementation detail. */
|
|
export function runPluginNpmCiWithRetry(
|
|
args: string[],
|
|
options: PluginSpawnOptions,
|
|
params: {
|
|
attempts?: number;
|
|
timeoutMs?: number;
|
|
spawn?: (args: string[], options: PluginSpawnOptions) => SpawnResult | undefined;
|
|
cleanupAttempt?: () => void;
|
|
pluginDir?: string;
|
|
} = {},
|
|
) {
|
|
const attempts = params.attempts ?? 3;
|
|
const timeoutMs = params.timeoutMs ?? 180_000;
|
|
const spawn = params.spawn ?? spawnNpmSync;
|
|
const cleanupAttempt = params.cleanupAttempt ?? (() => {});
|
|
const pluginDir = params.pluginDir ?? "plugin";
|
|
|
|
let result: SpawnResult | undefined;
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
result = spawn(args, { ...options, timeout: timeoutMs });
|
|
if (!result) {
|
|
throw new Error(`npm ci returned no result for ${pluginDir}`);
|
|
}
|
|
if (!isRecord(result.error) || result.error.code !== "ETIMEDOUT") {
|
|
return result;
|
|
}
|
|
|
|
// A timed-out npm process can leave a partial tree that makes the next
|
|
// package attempt nondeterministic. Restore the staging invariant even
|
|
// when the retry budget is exhausted.
|
|
cleanupAttempt();
|
|
if (attempt === attempts) {
|
|
return result;
|
|
}
|
|
console.error(
|
|
`[plugin-npm-publish] bundled dependency install timed out for ${pluginDir} ` +
|
|
`(attempt ${attempt}/${attempts}); retrying`,
|
|
);
|
|
}
|
|
throw new Error(`npm ci retry loop exhausted for ${pluginDir}`);
|
|
}
|
|
|
|
/** @internal Directly tested release-script implementation detail. */
|
|
export function generatePluginNpmPackageLockWithRetry(
|
|
packageDir: string,
|
|
options: PackageLockOptions = {},
|
|
params: {
|
|
attempts?: number;
|
|
timeoutMs?: number;
|
|
generate?: GeneratePackageLock;
|
|
pluginDir?: string;
|
|
} = {},
|
|
) {
|
|
const attempts = params.attempts ?? 3;
|
|
const timeoutMs = params.timeoutMs ?? 180_000;
|
|
const generate = params.generate ?? generateNpmPackageLock;
|
|
const pluginDir = params.pluginDir ?? "plugin";
|
|
const env = {
|
|
...(options.env ?? process.env),
|
|
OPENCLAW_NPM_LOCK_COMMAND_TIMEOUT_MS: String(timeoutMs),
|
|
};
|
|
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
try {
|
|
return generate(packageDir, { ...options, env });
|
|
} catch (error) {
|
|
if (!isRecord(error) || error.code !== "ETIMEDOUT" || attempt === attempts) {
|
|
throw error;
|
|
}
|
|
console.error(
|
|
`[plugin-npm-publish] package-lock generation timed out for ${pluginDir} ` +
|
|
`(attempt ${attempt}/${attempts}); retrying`,
|
|
);
|
|
}
|
|
}
|
|
throw new Error(`package-lock generation retry loop exhausted for ${pluginDir}`);
|
|
}
|
|
|
|
function resolveInstalledPackageDir(packageDir: string, packageName: string) {
|
|
return path.join(packageDir, "node_modules", ...packageName.split("/"));
|
|
}
|
|
|
|
function readInstalledPackageJson(packageDir: string, packageName: string) {
|
|
const packageJsonPath = path.join(
|
|
resolveInstalledPackageDir(packageDir, packageName),
|
|
"package.json",
|
|
);
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return {
|
|
packageDir: path.dirname(packageJsonPath),
|
|
packageJson: readJsonFile(packageJsonPath),
|
|
};
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function hasInstalledPackage(packageDir: string, packageName: string) {
|
|
return fs.existsSync(
|
|
path.join(resolveInstalledPackageDir(packageDir, packageName), "package.json"),
|
|
);
|
|
}
|
|
|
|
function normalizeOptionalDependencySpec(
|
|
packageDir: string,
|
|
dependencyPackageDir: string,
|
|
spec: unknown,
|
|
): string | undefined {
|
|
if (typeof spec !== "string" || !spec.trim()) {
|
|
return undefined;
|
|
}
|
|
const trimmed = spec.trim();
|
|
if (!trimmed.startsWith("file:")) {
|
|
return trimmed;
|
|
}
|
|
const fileTarget = trimmed.slice("file:".length);
|
|
if (!fileTarget || path.isAbsolute(fileTarget)) {
|
|
return trimmed;
|
|
}
|
|
const absoluteTarget = path.resolve(dependencyPackageDir, fileTarget);
|
|
const packageRelativeTarget = path.relative(packageDir, absoluteTarget).replaceAll(path.sep, "/");
|
|
return `file:${packageRelativeTarget.startsWith(".") ? packageRelativeTarget : `./${packageRelativeTarget}`}`;
|
|
}
|
|
|
|
function collectMissingOptionalBundledDependencySpecs(
|
|
packageDir: string,
|
|
packageJson: PluginPackageJson,
|
|
) {
|
|
const queue = listConfiguredBundledDependencyNames(packageJson);
|
|
const visited = new Set<string>();
|
|
const missing = new Map<string, string>();
|
|
|
|
while (queue.length > 0) {
|
|
const packageName = queue.shift();
|
|
if (!packageName || visited.has(packageName)) {
|
|
continue;
|
|
}
|
|
visited.add(packageName);
|
|
|
|
const installed = readInstalledPackageJson(packageDir, packageName);
|
|
if (!installed) {
|
|
continue;
|
|
}
|
|
const dependencyNames = [
|
|
...Object.keys(installed.packageJson.dependencies ?? {}),
|
|
...Object.keys(installed.packageJson.optionalDependencies ?? {}),
|
|
].toSorted((left, right) => left.localeCompare(right));
|
|
queue.push(...dependencyNames);
|
|
|
|
for (const [optionalName, optionalSpec] of Object.entries(
|
|
installed.packageJson.optionalDependencies ?? {},
|
|
).toSorted(([left], [right]) => left.localeCompare(right))) {
|
|
if (hasInstalledPackage(packageDir, optionalName)) {
|
|
continue;
|
|
}
|
|
const normalizedSpec = normalizeOptionalDependencySpec(
|
|
packageDir,
|
|
installed.packageDir,
|
|
optionalSpec,
|
|
);
|
|
if (normalizedSpec) {
|
|
missing.set(optionalName, normalizedSpec);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...missing.entries()].map(([name, spec]) => `${name}@${spec}`);
|
|
}
|
|
|
|
function installMissingOptionalBundledDependencies(params: PluginPackageContext) {
|
|
const portableOptionalInstallSpecs = new Map<string, string>();
|
|
for (let pass = 0; pass < 3; pass += 1) {
|
|
const installSpecs = collectMissingOptionalBundledDependencySpecs(
|
|
params.packageDir,
|
|
params.packageJson,
|
|
);
|
|
if (installSpecs.length === 0) {
|
|
return;
|
|
}
|
|
for (const installSpec of installSpecs) {
|
|
const at = installSpec.indexOf("@", installSpec.startsWith("@") ? 1 : 0);
|
|
const packageName = at > 0 ? installSpec.slice(0, at) : installSpec;
|
|
portableOptionalInstallSpecs.set(packageName, installSpec);
|
|
}
|
|
const cumulativeInstallSpecs = [...portableOptionalInstallSpecs.values()].toSorted(
|
|
(left, right) => left.localeCompare(right),
|
|
);
|
|
console.error(
|
|
`[plugin-npm-publish] installing portable optional bundled dependencies for ${params.pluginDir}: ${cumulativeInstallSpecs.join(", ")}`,
|
|
);
|
|
const result = spawnNpmSync(
|
|
[
|
|
"install",
|
|
"--force",
|
|
"--omit=dev",
|
|
"--omit=peer",
|
|
"--legacy-peer-deps",
|
|
"--ignore-scripts",
|
|
"--no-audit",
|
|
"--no-fund",
|
|
"--package-lock=false",
|
|
"--save=false",
|
|
"--loglevel=error",
|
|
...cumulativeInstallSpecs,
|
|
],
|
|
{
|
|
cwd: params.packageDir,
|
|
env: process.env,
|
|
stdio: ["ignore", "ignore", "inherit"],
|
|
},
|
|
);
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if ((result.status ?? 1) !== 0) {
|
|
throw new Error(
|
|
`package-local portable optional dependency install failed for ${params.pluginDir} with exit ${result.status ?? 1}`,
|
|
);
|
|
}
|
|
}
|
|
const remainingSpecs = collectMissingOptionalBundledDependencySpecs(
|
|
params.packageDir,
|
|
params.packageJson,
|
|
);
|
|
if (remainingSpecs.length > 0) {
|
|
throw new Error(
|
|
`package-local portable optional dependency install did not settle for ${params.pluginDir}: ${remainingSpecs.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function packageOptsOutOfBundledRuntimeDependencies(packageJson: PluginPackageJson | undefined) {
|
|
return packageJson?.openclaw?.release?.bundleRuntimeDependencies === false;
|
|
}
|
|
|
|
function shouldBundleDependencies(value: unknown, packageJson: PluginPackageJson | undefined) {
|
|
if (packageOptsOutOfBundledRuntimeDependencies(packageJson)) {
|
|
return false;
|
|
}
|
|
return value === true || value === "1" || value === "true";
|
|
}
|
|
|
|
function installPackageLocalBundledDependencies(params: PluginPackageContext) {
|
|
const packageJson = params.packageJson;
|
|
if (
|
|
!hasPackageRuntimeDependencies(packageJson) ||
|
|
listConfiguredBundledDependencyNames(packageJson).length === 0
|
|
) {
|
|
return () => {};
|
|
}
|
|
|
|
const packageLockPath = path.join(params.packageDir, "package-lock.json");
|
|
if (fs.existsSync(packageLockPath)) {
|
|
throw new Error(
|
|
`package-local bundled dependency install refuses to replace existing package-lock.json for ${params.pluginDir}`,
|
|
);
|
|
}
|
|
|
|
const nodeModulesPath = path.join(params.packageDir, "node_modules");
|
|
if (fs.existsSync(nodeModulesPath)) {
|
|
throw new Error(
|
|
`package-local bundled dependency install refuses to replace existing node_modules for ${params.pluginDir}`,
|
|
);
|
|
}
|
|
|
|
console.error(`[plugin-npm-publish] installing bundled dependencies for ${params.pluginDir}`);
|
|
const packageJsonPath = resolvePackageJsonPath(params.packageDir);
|
|
const packedPackageJsonText = fs.readFileSync(packageJsonPath, "utf8");
|
|
const installPackageJsonBase = {
|
|
...params.packageJson,
|
|
};
|
|
delete installPackageJsonBase.peerDependencies;
|
|
delete installPackageJsonBase.peerDependenciesMeta;
|
|
const installPackageJson = packageJsonForNpmLock(installPackageJsonBase, readNpmLockOverrides());
|
|
const installPackageJsonText = `${JSON.stringify(installPackageJson, null, 2)}\n`;
|
|
if (installPackageJsonText !== packedPackageJsonText) {
|
|
// npm validates peer edges against the package lock during ci even when peers are omitted.
|
|
// The peer metadata belongs in the packed plugin, not in this temporary dependency install.
|
|
fs.writeFileSync(packageJsonPath, installPackageJsonText, "utf8");
|
|
}
|
|
try {
|
|
fs.writeFileSync(
|
|
packageLockPath,
|
|
generatePluginNpmPackageLockWithRetry(
|
|
params.packageDir,
|
|
{ installStrategy: "shallow" },
|
|
{ pluginDir: params.pluginDir },
|
|
),
|
|
"utf8",
|
|
);
|
|
const result = runPluginNpmCiWithRetry(
|
|
[
|
|
"ci",
|
|
"--install-strategy=shallow",
|
|
"--omit=dev",
|
|
"--omit=peer",
|
|
"--legacy-peer-deps",
|
|
"--ignore-scripts",
|
|
"--no-audit",
|
|
"--no-fund",
|
|
"--workspaces=false",
|
|
"--loglevel=error",
|
|
],
|
|
{
|
|
cwd: params.packageDir,
|
|
env: process.env,
|
|
stdio: ["ignore", "ignore", "inherit"],
|
|
},
|
|
{
|
|
cleanupAttempt: () => fs.rmSync(nodeModulesPath, { recursive: true, force: true }),
|
|
pluginDir: params.pluginDir,
|
|
},
|
|
);
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if ((result.status ?? 1) !== 0) {
|
|
throw new Error(
|
|
`package-local bundled dependency install failed for ${params.pluginDir} with exit ${result.status ?? 1}`,
|
|
);
|
|
}
|
|
installMissingOptionalBundledDependencies(params);
|
|
} finally {
|
|
fs.writeFileSync(packageJsonPath, packedPackageJsonText, "utf8");
|
|
fs.rmSync(packageLockPath, { force: true });
|
|
}
|
|
return () => {
|
|
fs.rmSync(nodeModulesPath, { recursive: true, force: true });
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the package.json that should be used while packaging a plugin for npm.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function resolveAugmentedPluginNpmPackageJson(params: PluginPackageParams) {
|
|
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 packagedChannel = resolvePackagedChannelMetadata(plan);
|
|
const packageJson: PluginPackageJson = {
|
|
...plan.packageJson,
|
|
files: plan.packageFiles,
|
|
peerDependencies: plan.packagePeerMetadata.peerDependencies,
|
|
peerDependenciesMeta: plan.packagePeerMetadata.peerDependenciesMeta,
|
|
openclaw: {
|
|
...plan.packageJson.openclaw,
|
|
...(packagedChannel ? { channel: packagedChannel } : {}),
|
|
runtimeExtensions: plan.runtimeExtensions,
|
|
...(plan.runtimeSetupEntry
|
|
? {
|
|
setupEntry: plan.runtimeSetupEntry,
|
|
runtimeSetupEntry: plan.runtimeSetupEntry,
|
|
}
|
|
: {}),
|
|
},
|
|
};
|
|
if (shouldBundleDependencies(params.bundleDependencies, plan.packageJson)) {
|
|
packageJson.bundledDependencies = listPackageRuntimeDependencyNames(packageJson);
|
|
delete packageJson.bundleDependencies;
|
|
delete packageJson.devDependencies;
|
|
}
|
|
const changed = JSON.stringify(packageJson) !== JSON.stringify(plan.packageJson);
|
|
return {
|
|
packageJsonPath,
|
|
packageDir,
|
|
repoRoot,
|
|
changed,
|
|
packageJson,
|
|
pluginDir: plan.pluginDir,
|
|
bundleDependencies: shouldBundleDependencies(params.bundleDependencies, plan.packageJson),
|
|
reason: changed ? "package-local-runtime" : "unchanged",
|
|
};
|
|
}
|
|
|
|
/** Read generated bundled channel config metadata keyed by plugin id. */
|
|
export function readGeneratedBundledChannelConfigs(repoRoot: string) {
|
|
const metadataPath = path.join(repoRoot, GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA_PATH);
|
|
if (!fs.existsSync(metadataPath)) {
|
|
return new Map<string, GeneratedChannelConfigs>();
|
|
}
|
|
const source = fs.readFileSync(metadataPath, "utf8");
|
|
const entries = readGeneratedBundledChannelConfigEntries(source);
|
|
if (!Array.isArray(entries)) {
|
|
return new Map<string, GeneratedChannelConfigs>();
|
|
}
|
|
|
|
const byPlugin = new Map<string, GeneratedChannelConfigs>();
|
|
for (const entry of entries) {
|
|
if (
|
|
!isRecord(entry) ||
|
|
typeof entry.pluginId !== "string" ||
|
|
typeof entry.channelId !== "string" ||
|
|
!isRecord(entry.schema)
|
|
) {
|
|
continue;
|
|
}
|
|
const pluginConfigs = byPlugin.get(entry.pluginId) ?? {};
|
|
pluginConfigs[entry.channelId] = {
|
|
schema: entry.schema,
|
|
...(typeof entry.label === "string" && entry.label ? { label: entry.label } : {}),
|
|
...(typeof entry.description === "string" && entry.description
|
|
? { description: entry.description }
|
|
: {}),
|
|
...(isRecord(entry.uiHints) ? { uiHints: entry.uiHints } : {}),
|
|
};
|
|
byPlugin.set(entry.pluginId, pluginConfigs);
|
|
}
|
|
return byPlugin;
|
|
}
|
|
|
|
function readGeneratedBundledChannelConfigEntries(source: string): unknown {
|
|
const legacyMatch = source.match(
|
|
/export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = ([\s\S]*?) as const;/u,
|
|
);
|
|
if (legacyMatch?.[1]) {
|
|
try {
|
|
return JSON5.parse(legacyMatch[1]);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
const compactMatch = source.match(
|
|
/const RAW_BUNDLED_CHANNEL_CONFIG_METADATA = \[([\s\S]*?)\]\.join\(""\);/u,
|
|
);
|
|
if (!compactMatch?.[1]) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const chunks = JSON5.parse(`[${compactMatch[1]}]`);
|
|
if (!Array.isArray(chunks) || chunks.some((chunk) => typeof chunk !== "string")) {
|
|
return undefined;
|
|
}
|
|
return JSON.parse(chunks.join(""));
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Merge generated channel config schemas into a plugin manifest without clobbering labels. */
|
|
export function mergeGeneratedChannelConfigs(
|
|
manifest: JsonRecord,
|
|
generatedChannelConfigs: GeneratedChannelConfigs | undefined,
|
|
) {
|
|
if (!generatedChannelConfigs || Object.keys(generatedChannelConfigs).length === 0) {
|
|
return manifest;
|
|
}
|
|
const existingChannelConfigs = isRecord(manifest.channelConfigs) ? manifest.channelConfigs : {};
|
|
const channelConfigs: JsonRecord = { ...existingChannelConfigs };
|
|
for (const [channelId, generated] of Object.entries(generatedChannelConfigs)) {
|
|
const existing = isRecord(existingChannelConfigs[channelId])
|
|
? existingChannelConfigs[channelId]
|
|
: {};
|
|
const existingUiHints = isRecord(existing.uiHints) ? existing.uiHints : {};
|
|
channelConfigs[channelId] = {
|
|
...generated,
|
|
...existing,
|
|
schema: generated.schema,
|
|
...(generated.uiHints || Object.keys(existingUiHints).length > 0
|
|
? { uiHints: { ...generated.uiHints, ...existingUiHints } }
|
|
: {}),
|
|
...(existing.label || generated.label ? { label: existing.label ?? generated.label } : {}),
|
|
...(existing.description || generated.description
|
|
? { description: existing.description ?? generated.description }
|
|
: {}),
|
|
};
|
|
}
|
|
return {
|
|
...manifest,
|
|
channelConfigs,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the plugin manifest that should be used while packaging a plugin for npm.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function resolveAugmentedPluginNpmManifest(params: PluginPackageParams) {
|
|
const repoRoot = path.resolve(params.repoRoot ?? ".");
|
|
const packageDir = resolvePackageDir(repoRoot, params.packageDir);
|
|
const manifestPath = path.join(packageDir, "openclaw.plugin.json");
|
|
if (!fs.existsSync(manifestPath)) {
|
|
return {
|
|
manifestPath,
|
|
pluginId: path.basename(packageDir),
|
|
changed: false,
|
|
manifest: undefined,
|
|
reason: "missing-manifest",
|
|
};
|
|
}
|
|
|
|
const manifest = readJsonFile(manifestPath);
|
|
const pluginId =
|
|
typeof manifest.id === "string" && manifest.id ? manifest.id : path.basename(packageDir);
|
|
const generatedChannelConfigs = readGeneratedBundledChannelConfigs(repoRoot).get(pluginId);
|
|
const augmentedManifest = mergeGeneratedChannelConfigs(manifest, generatedChannelConfigs);
|
|
const changed = JSON.stringify(augmentedManifest) !== JSON.stringify(manifest);
|
|
return {
|
|
manifestPath,
|
|
pluginId,
|
|
changed,
|
|
manifest: augmentedManifest,
|
|
reason: changed ? "generated-channel-configs" : "unchanged",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Temporarily write augmented manifest/package metadata while a packaging callback runs.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
type ManifestOverlayContext = ReturnType<typeof resolveAugmentedPluginNpmManifest> & {
|
|
applied: boolean;
|
|
packageDir: string;
|
|
packageJsonApplied: boolean;
|
|
repoRoot: string;
|
|
};
|
|
|
|
export function withAugmentedPluginNpmManifestForPackage<T>(
|
|
params: PluginPackageParams,
|
|
callback: (context: ManifestOverlayContext) => T,
|
|
): T {
|
|
const repoRoot = path.resolve(params.repoRoot ?? ".");
|
|
const packageDir = resolvePackageDir(repoRoot, params.packageDir);
|
|
const packageJsonPath = resolvePackageJsonPath(packageDir);
|
|
const packageJsonForBundlePolicy = fs.existsSync(packageJsonPath)
|
|
? readJsonFile(packageJsonPath)
|
|
: undefined;
|
|
const bundleDependencies = shouldBundleDependencies(
|
|
params.bundleDependencies,
|
|
packageJsonForBundlePolicy,
|
|
);
|
|
const resolvedManifest = resolveAugmentedPluginNpmManifest({
|
|
repoRoot,
|
|
packageDir,
|
|
});
|
|
const resolvedPackageJson = resolveAugmentedPluginNpmPackageJson({
|
|
repoRoot,
|
|
packageDir,
|
|
bundleDependencies,
|
|
});
|
|
|
|
if (
|
|
(!resolvedManifest.changed || !resolvedManifest.manifest) &&
|
|
(!resolvedPackageJson.changed || !resolvedPackageJson.packageJson)
|
|
) {
|
|
return callback({
|
|
...resolvedManifest,
|
|
packageDir,
|
|
repoRoot,
|
|
applied: false,
|
|
packageJsonApplied: false,
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
let cleanupBundledDependencies = () => {};
|
|
try {
|
|
if (bundleDependencies && resolvedPackageJson.packageJson) {
|
|
cleanupBundledDependencies = installPackageLocalBundledDependencies({
|
|
packageDir,
|
|
packageJson: resolvedPackageJson.packageJson,
|
|
pluginDir: resolvedPackageJson.pluginDir ?? path.basename(packageDir),
|
|
});
|
|
}
|
|
return callback({
|
|
...resolvedManifest,
|
|
packageDir,
|
|
repoRoot,
|
|
applied: resolvedManifest.changed && Boolean(resolvedManifest.manifest),
|
|
packageJsonApplied: resolvedPackageJson.changed && Boolean(resolvedPackageJson.packageJson),
|
|
});
|
|
} finally {
|
|
cleanupBundledDependencies();
|
|
if (originalManifest !== undefined) {
|
|
fs.writeFileSync(resolvedManifest.manifestPath, originalManifest, "utf8");
|
|
}
|
|
if (originalPackageJson !== undefined) {
|
|
fs.writeFileSync(resolvedPackageJson.packageJsonPath, originalPackageJson, "utf8");
|
|
}
|
|
}
|
|
}
|
|
|
|
const RUN_USAGE =
|
|
"usage: node scripts/lib/plugin-npm-package-manifest.mjs --run <package-dir> -- <command> [args...]";
|
|
|
|
function readRunPackageDir(argv: string[]) {
|
|
const packageDir = argv[1];
|
|
if (!packageDir || packageDir.startsWith("--")) {
|
|
throw new Error(RUN_USAGE);
|
|
}
|
|
return packageDir;
|
|
}
|
|
|
|
/** @internal Directly tested script implementation detail. */
|
|
export function parseRunArgs(
|
|
argv: string[],
|
|
):
|
|
| { help: true; packageDir: string; command: string; args: string[] }
|
|
| { packageDir: string; command: string; args: string[]; help?: undefined } {
|
|
if (argv[0] === "--help" || argv[0] === "-h") {
|
|
return { help: true, packageDir: "", command: "", args: [] };
|
|
}
|
|
if (argv[0] !== "--run") {
|
|
throw new Error(RUN_USAGE);
|
|
}
|
|
const packageDir = readRunPackageDir(argv);
|
|
const separatorIndex = argv.indexOf("--", 2);
|
|
if (!packageDir || separatorIndex === -1 || separatorIndex === argv.length - 1) {
|
|
throw new Error(RUN_USAGE);
|
|
}
|
|
if (separatorIndex !== 2) {
|
|
throw new Error(`unexpected plugin npm package manifest run argument: ${argv[2]}`);
|
|
}
|
|
const command = argv[separatorIndex + 1];
|
|
if (!command) {
|
|
throw new Error(RUN_USAGE);
|
|
}
|
|
return {
|
|
packageDir,
|
|
command,
|
|
args: argv.slice(separatorIndex + 2),
|
|
};
|
|
}
|
|
|
|
function main(argv: string[] = process.argv.slice(2)) {
|
|
const parsedArgs = parseRunArgs(argv);
|
|
if (parsedArgs.help) {
|
|
console.log(RUN_USAGE);
|
|
return 0;
|
|
}
|
|
const { packageDir, command, args } = parsedArgs;
|
|
return withAugmentedPluginNpmManifestForPackage(
|
|
{
|
|
packageDir,
|
|
bundleDependencies: process.env.OPENCLAW_PLUGIN_NPM_BUNDLE_DEPENDENCIES,
|
|
},
|
|
({ packageDir: cwd }) => {
|
|
const result = spawnCommandSync(command, args, {
|
|
cwd,
|
|
env: process.env,
|
|
stdio: "inherit",
|
|
});
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
return result.status ?? 1;
|
|
},
|
|
);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
try {
|
|
process.exitCode = main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|