mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-14 19:40:40 +00:00
refactor: harden plugin install flow and main DM route pinning
This commit is contained in:
@@ -6,13 +6,13 @@ 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 {
|
||||
type BundledPluginSource,
|
||||
findBundledPluginByNpmSpec,
|
||||
findBundledPluginByPluginId,
|
||||
} from "../plugins/bundled-sources.js";
|
||||
import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
|
||||
import {
|
||||
installPluginFromNpmSpec,
|
||||
installPluginFromPath,
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
} from "../plugins/install.js";
|
||||
import { recordPluginInstall } from "../plugins/installs.js";
|
||||
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
|
||||
import type { PluginRecord } from "../plugins/registry.js";
|
||||
@@ -153,16 +153,6 @@ function logSlotWarnings(warnings: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function isPackageNotFoundInstallError(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes("npm pack failed:") &&
|
||||
(lower.includes("e404") ||
|
||||
lower.includes("404 not found") ||
|
||||
lower.includes("could not be found"))
|
||||
);
|
||||
}
|
||||
|
||||
function isBareNpmPackageName(spec: string): boolean {
|
||||
const trimmed = spec.trim();
|
||||
return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed);
|
||||
@@ -210,6 +200,174 @@ async function installBundledPluginSource(params: {
|
||||
defaultRuntime.log(`Installed plugin: ${params.bundledSource.pluginId}`);
|
||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||
}
|
||||
|
||||
async function runPluginInstallCommand(params: {
|
||||
raw: string;
|
||||
opts: { link?: boolean; pin?: boolean };
|
||||
}) {
|
||||
const { raw, opts } = params;
|
||||
const fileSpec = resolveFileNpmSpecToLocalPath(raw);
|
||||
if (fileSpec && !fileSpec.ok) {
|
||||
defaultRuntime.error(fileSpec.error);
|
||||
process.exit(1);
|
||||
}
|
||||
const normalized = fileSpec && fileSpec.ok ? fileSpec.path : raw;
|
||||
const resolved = resolveUserPath(normalized);
|
||||
const cfg = loadConfig();
|
||||
|
||||
if (fs.existsSync(resolved)) {
|
||||
if (opts.link) {
|
||||
const existing = cfg.plugins?.load?.paths ?? [];
|
||||
const merged = Array.from(new Set([...existing, resolved]));
|
||||
const probe = await installPluginFromPath({ path: resolved, dryRun: true });
|
||||
if (!probe.ok) {
|
||||
defaultRuntime.error(probe.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let next: OpenClawConfig = enablePluginInConfig(
|
||||
{
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
load: {
|
||||
...cfg.plugins?.load,
|
||||
paths: merged,
|
||||
},
|
||||
},
|
||||
},
|
||||
probe.pluginId,
|
||||
).config;
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: probe.pluginId,
|
||||
source: "path",
|
||||
sourcePath: resolved,
|
||||
installPath: resolved,
|
||||
version: probe.version,
|
||||
});
|
||||
const slotResult = applySlotSelectionForPlugin(next, probe.pluginId);
|
||||
next = slotResult.config;
|
||||
await writeConfigFile(next);
|
||||
logSlotWarnings(slotResult.warnings);
|
||||
defaultRuntime.log(`Linked plugin path: ${shortenHomePath(resolved)}`);
|
||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await installPluginFromPath({
|
||||
path: resolved,
|
||||
logger: createPluginInstallLogger(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
defaultRuntime.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
// Plugin CLI registrars may have warmed the manifest registry cache before install;
|
||||
// force a rescan so config validation sees the freshly installed plugin.
|
||||
clearPluginManifestRegistryCache();
|
||||
|
||||
let next = enablePluginInConfig(cfg, result.pluginId).config;
|
||||
const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path";
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: result.pluginId,
|
||||
source,
|
||||
sourcePath: resolved,
|
||||
installPath: result.targetDir,
|
||||
version: result.version,
|
||||
});
|
||||
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
||||
next = slotResult.config;
|
||||
await writeConfigFile(next);
|
||||
logSlotWarnings(slotResult.warnings);
|
||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.link) {
|
||||
defaultRuntime.error("`--link` requires a local path.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (
|
||||
looksLikeLocalInstallSpec(raw, [
|
||||
".ts",
|
||||
".js",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".tgz",
|
||||
".tar.gz",
|
||||
".tar",
|
||||
".zip",
|
||||
])
|
||||
) {
|
||||
defaultRuntime.error(`Path not found: ${resolved}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bundledByPluginId = isBareNpmPackageName(raw)
|
||||
? findBundledPluginSource({
|
||||
lookup: { kind: "pluginId", value: 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 bundledFallback =
|
||||
result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND
|
||||
? findBundledPluginSource({
|
||||
lookup: { kind: "npmSpec", value: raw },
|
||||
})
|
||||
: undefined;
|
||||
if (!bundledFallback) {
|
||||
defaultRuntime.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await installBundledPluginSource({
|
||||
config: cfg,
|
||||
rawSpec: raw,
|
||||
bundledSource: bundledFallback,
|
||||
warning: `npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup.
|
||||
clearPluginManifestRegistryCache();
|
||||
|
||||
let next = enablePluginInConfig(cfg, result.pluginId).config;
|
||||
const installRecord = resolvePinnedNpmInstallRecordForCli(
|
||||
raw,
|
||||
Boolean(opts.pin),
|
||||
result.targetDir,
|
||||
result.version,
|
||||
result.npmResolution,
|
||||
defaultRuntime.log,
|
||||
theme.warn,
|
||||
);
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: result.pluginId,
|
||||
...installRecord,
|
||||
});
|
||||
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
||||
next = slotResult.config;
|
||||
await writeConfigFile(next);
|
||||
logSlotWarnings(slotResult.warnings);
|
||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||
}
|
||||
export function registerPluginsCli(program: Command) {
|
||||
const plugins = program
|
||||
.command("plugins")
|
||||
@@ -572,162 +730,7 @@ export function registerPluginsCli(program: Command) {
|
||||
.option("-l, --link", "Link a local path instead of copying", false)
|
||||
.option("--pin", "Record npm installs as exact resolved <name>@<version>", false)
|
||||
.action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => {
|
||||
const fileSpec = resolveFileNpmSpecToLocalPath(raw);
|
||||
if (fileSpec && !fileSpec.ok) {
|
||||
defaultRuntime.error(fileSpec.error);
|
||||
process.exit(1);
|
||||
}
|
||||
const normalized = fileSpec && fileSpec.ok ? fileSpec.path : raw;
|
||||
const resolved = resolveUserPath(normalized);
|
||||
const cfg = loadConfig();
|
||||
|
||||
if (fs.existsSync(resolved)) {
|
||||
if (opts.link) {
|
||||
const existing = cfg.plugins?.load?.paths ?? [];
|
||||
const merged = Array.from(new Set([...existing, resolved]));
|
||||
const probe = await installPluginFromPath({ path: resolved, dryRun: true });
|
||||
if (!probe.ok) {
|
||||
defaultRuntime.error(probe.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let next: OpenClawConfig = enablePluginInConfig(
|
||||
{
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
load: {
|
||||
...cfg.plugins?.load,
|
||||
paths: merged,
|
||||
},
|
||||
},
|
||||
},
|
||||
probe.pluginId,
|
||||
).config;
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: probe.pluginId,
|
||||
source: "path",
|
||||
sourcePath: resolved,
|
||||
installPath: resolved,
|
||||
version: probe.version,
|
||||
});
|
||||
const slotResult = applySlotSelectionForPlugin(next, probe.pluginId);
|
||||
next = slotResult.config;
|
||||
await writeConfigFile(next);
|
||||
logSlotWarnings(slotResult.warnings);
|
||||
defaultRuntime.log(`Linked plugin path: ${shortenHomePath(resolved)}`);
|
||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await installPluginFromPath({
|
||||
path: resolved,
|
||||
logger: createPluginInstallLogger(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
defaultRuntime.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
// Plugin CLI registrars may have warmed the manifest registry cache before install;
|
||||
// force a rescan so config validation sees the freshly installed plugin.
|
||||
clearPluginManifestRegistryCache();
|
||||
|
||||
let next = enablePluginInConfig(cfg, result.pluginId).config;
|
||||
const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path";
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: result.pluginId,
|
||||
source,
|
||||
sourcePath: resolved,
|
||||
installPath: result.targetDir,
|
||||
version: result.version,
|
||||
});
|
||||
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
||||
next = slotResult.config;
|
||||
await writeConfigFile(next);
|
||||
logSlotWarnings(slotResult.warnings);
|
||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.link) {
|
||||
defaultRuntime.error("`--link` requires a local path.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (
|
||||
looksLikeLocalInstallSpec(raw, [
|
||||
".ts",
|
||||
".js",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".tgz",
|
||||
".tar.gz",
|
||||
".tar",
|
||||
".zip",
|
||||
])
|
||||
) {
|
||||
defaultRuntime.error(`Path not found: ${resolved}`);
|
||||
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 bundledFallback = isPackageNotFoundInstallError(result.error)
|
||||
? findBundledPluginByNpmSpec({ spec: raw })
|
||||
: undefined;
|
||||
if (!bundledFallback) {
|
||||
defaultRuntime.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await installBundledPluginSource({
|
||||
config: cfg,
|
||||
rawSpec: raw,
|
||||
bundledSource: bundledFallback,
|
||||
warning: `npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup.
|
||||
clearPluginManifestRegistryCache();
|
||||
|
||||
let next = enablePluginInConfig(cfg, result.pluginId).config;
|
||||
const installRecord = resolvePinnedNpmInstallRecordForCli(
|
||||
raw,
|
||||
Boolean(opts.pin),
|
||||
result.targetDir,
|
||||
result.version,
|
||||
result.npmResolution,
|
||||
defaultRuntime.log,
|
||||
theme.warn,
|
||||
);
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: result.pluginId,
|
||||
...installRecord,
|
||||
});
|
||||
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
||||
next = slotResult.config;
|
||||
await writeConfigFile(next);
|
||||
logSlotWarnings(slotResult.warnings);
|
||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||
await runPluginInstallCommand({ raw, opts });
|
||||
});
|
||||
|
||||
plugins
|
||||
|
||||
Reference in New Issue
Block a user