fix(plugins): prefer bundled plugin ids over bare npm specs

This commit is contained in:
Peter Steinberger
2026-03-02 20:46:28 +00:00
parent bfb6c6290f
commit ad12d1fbce
4 changed files with 97 additions and 60 deletions

View File

@@ -6,7 +6,11 @@ import type { OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { resolveArchiveKind } from "../infra/archive.js";
import { findBundledPluginByNpmSpec } from "../plugins/bundled-sources.js";
import {
type BundledPluginSource,
findBundledPluginByNpmSpec,
findBundledPluginByPluginId,
} from "../plugins/bundled-sources.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js";
@@ -159,19 +163,53 @@ function isPackageNotFoundInstallError(message: string): boolean {
);
}
/**
* True when npm downloaded a package successfully but it is not a valid
* OpenClaw plugin (e.g. `diffs` resolves to the unrelated npm package
* `diffs@0.1.1` instead of `@openclaw/diffs`).
* See: https://github.com/openclaw/openclaw/issues/32019
*/
function isNotAnOpenClawPluginError(message: string): boolean {
const lower = message.toLowerCase();
return (
lower.includes("missing openclaw.extensions") || lower.includes("openclaw.extensions is empty")
);
function isBareNpmPackageName(spec: string): boolean {
const trimmed = spec.trim();
return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed);
}
async function installBundledPluginSource(params: {
config: OpenClawConfig;
rawSpec: string;
bundledSource: BundledPluginSource;
warning: string;
}) {
const existing = params.config.plugins?.load?.paths ?? [];
const mergedPaths = Array.from(new Set([...existing, params.bundledSource.localPath]));
let next: OpenClawConfig = {
...params.config,
plugins: {
...params.config.plugins,
load: {
...params.config.plugins?.load,
paths: mergedPaths,
},
entries: {
...params.config.plugins?.entries,
[params.bundledSource.pluginId]: {
...(params.config.plugins?.entries?.[params.bundledSource.pluginId] as
| object
| undefined),
enabled: true,
},
},
},
};
next = recordPluginInstall(next, {
pluginId: params.bundledSource.pluginId,
source: "path",
spec: params.rawSpec,
sourcePath: params.bundledSource.localPath,
installPath: params.bundledSource.localPath,
});
const slotResult = applySlotSelectionForPlugin(next, params.bundledSource.pluginId);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(theme.warn(params.warning));
defaultRuntime.log(`Installed plugin: ${params.bundledSource.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
}
export function registerPluginsCli(program: Command) {
const plugins = program
.command("plugins")
@@ -633,59 +671,38 @@ export function registerPluginsCli(program: Command) {
process.exit(1);
}
const bundledByPluginId = isBareNpmPackageName(raw)
? findBundledPluginByPluginId({ pluginId: raw })
: undefined;
if (bundledByPluginId) {
await installBundledPluginSource({
config: cfg,
rawSpec: raw,
bundledSource: bundledByPluginId,
warning: `Using bundled plugin "${bundledByPluginId.pluginId}" from ${shortenHomePath(bundledByPluginId.localPath)} for bare install spec "${raw}". To install an npm package with the same name, use a scoped package name (for example @scope/${raw}).`,
});
return;
}
const result = await installPluginFromNpmSpec({
spec: raw,
logger: createPluginInstallLogger(),
});
if (!result.ok) {
const isNpmNotFound = isPackageNotFoundInstallError(result.error);
const isNotPlugin = isNotAnOpenClawPluginError(result.error);
const bundledFallback =
isNpmNotFound || isNotPlugin ? findBundledPluginByNpmSpec({ spec: raw }) : undefined;
const bundledFallback = isPackageNotFoundInstallError(result.error)
? findBundledPluginByNpmSpec({ spec: raw })
: undefined;
if (!bundledFallback) {
defaultRuntime.error(result.error);
process.exit(1);
}
const existing = cfg.plugins?.load?.paths ?? [];
const mergedPaths = Array.from(new Set([...existing, bundledFallback.localPath]));
let next: OpenClawConfig = {
...cfg,
plugins: {
...cfg.plugins,
load: {
...cfg.plugins?.load,
paths: mergedPaths,
},
entries: {
...cfg.plugins?.entries,
[bundledFallback.pluginId]: {
...(cfg.plugins?.entries?.[bundledFallback.pluginId] as object | undefined),
enabled: true,
},
},
},
};
next = recordPluginInstall(next, {
pluginId: bundledFallback.pluginId,
source: "path",
spec: raw,
sourcePath: bundledFallback.localPath,
installPath: bundledFallback.localPath,
await installBundledPluginSource({
config: cfg,
rawSpec: raw,
bundledSource: bundledFallback,
warning: `npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`,
});
const slotResult = applySlotSelectionForPlugin(next, bundledFallback.pluginId);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(
theme.warn(
isNpmNotFound
? `npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`
: `npm package "${raw}" is not a valid OpenClaw plugin; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`,
),
);
defaultRuntime.log(`Installed plugin: ${bundledFallback.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
return;
}
// Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup.