Files
openclaw/src/cli/plugins-install-persist.ts
2026-05-04 03:28:53 -07:00

306 lines
9.5 KiB
TypeScript

import { replaceConfigFile } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { type HookInstallUpdate, recordHookInstall } from "../hooks/installs.js";
import { isPathInside } from "../infra/path-guards.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import {
loadInstalledPluginIndexInstallRecords,
recordPluginInstallInRecords,
withoutPluginInstallRecords,
} from "../plugins/installed-plugin-index-records.js";
import type { PluginInstallUpdate } from "../plugins/installs.js";
import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
import { buildPluginSnapshotReport } from "../plugins/status.js";
import {
applyPluginUninstallDirectoryRemoval,
planPluginUninstall,
type PluginUninstallDirectoryRemoval,
} from "../plugins/uninstall.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import {
applySlotSelectionForPlugin,
enableInternalHookEntries,
logHookPackRestartHint,
logSlotWarnings,
} from "./plugins-command-helpers.js";
import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js";
import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js";
function addInstalledPluginToAllowlist(cfg: OpenClawConfig, pluginId: string): OpenClawConfig {
const allow = cfg.plugins?.allow;
if (!Array.isArray(allow) || allow.length === 0 || allow.includes(pluginId)) {
return cfg;
}
return {
...cfg,
plugins: {
...cfg.plugins,
allow: [...allow, pluginId].toSorted(),
},
};
}
function removeInstalledPluginFromDenylist(cfg: OpenClawConfig, pluginId: string): OpenClawConfig {
const deny = cfg.plugins?.deny;
if (!Array.isArray(deny) || !deny.includes(pluginId)) {
return cfg;
}
const nextDeny = deny.filter((id) => id !== pluginId);
const plugins = {
...cfg.plugins,
...(nextDeny.length > 0 ? { deny: nextDeny } : {}),
};
if (nextDeny.length === 0) {
delete plugins.deny;
}
return {
...cfg,
plugins,
};
}
export type ConfigSnapshotForInstallPersist = {
config: OpenClawConfig;
baseHash: string | undefined;
};
function sourceMatchesInstalledPath(params: {
activeSource: string;
installedSource: string;
env?: NodeJS.ProcessEnv;
}): boolean {
const activeSource = resolveUserPath(params.activeSource, params.env);
const installedSource = resolveUserPath(params.installedSource, params.env);
return activeSource === installedSource || isPathInside(installedSource, activeSource);
}
function logShadowedNpmInstallWarning(params: {
config: OpenClawConfig;
pluginId: string;
install: Omit<PluginInstallUpdate, "pluginId">;
runtime: RuntimeEnv;
}): void {
if (params.install.source !== "npm") {
return;
}
const installedSource = params.install.installPath ?? params.install.sourcePath;
if (!installedSource) {
return;
}
const report = buildPluginSnapshotReport({
config: params.config,
effectiveOnly: true,
onlyPluginIds: [params.pluginId],
});
const active = report.plugins.find((plugin) => plugin.id === params.pluginId);
if (
!active ||
active.origin !== "config" ||
sourceMatchesInstalledPath({ activeSource: active.source, installedSource })
) {
return;
}
params.runtime.log(
theme.warn(
[
`Warning: installed plugin "${params.pluginId}" is not the active source because a config-selected plugin with the same id is currently selected:`,
` active config source: ${shortenHomePath(active.source)}`,
` installed npm source: ${shortenHomePath(installedSource)}`,
"Run `openclaw plugins doctor` for repair options.",
].join("\n"),
),
);
}
function resolveComparableInstallPath(
install: Pick<PluginInstallRecord, "installPath" | "sourcePath">,
) {
return install.installPath ?? install.sourcePath;
}
function shouldPreserveReplacedInstallPath(params: {
removalTarget: string;
nextInstallPath: string;
}) {
const removalTarget = resolveUserPath(params.removalTarget);
const nextInstallPath = resolveUserPath(params.nextInstallPath);
return (
isPathInside(removalTarget, nextInstallPath) || isPathInside(nextInstallPath, removalTarget)
);
}
function resolveReplacedManagedInstallRemoval(params: {
pluginId: string;
previousInstall?: PluginInstallRecord;
nextInstall: Omit<PluginInstallUpdate, "pluginId">;
}): PluginUninstallDirectoryRemoval | null {
if (!params.previousInstall) {
return null;
}
const previousInstallPath = resolveComparableInstallPath(params.previousInstall);
const nextInstallPath = resolveComparableInstallPath(params.nextInstall);
if (!previousInstallPath || !nextInstallPath) {
return null;
}
if (
shouldPreserveReplacedInstallPath({
removalTarget: previousInstallPath,
nextInstallPath,
})
) {
return null;
}
const plan = planPluginUninstall({
config: {
plugins: {
installs: {
[params.pluginId]: params.previousInstall,
},
},
} as OpenClawConfig,
pluginId: params.pluginId,
deleteFiles: true,
});
if (!plan.ok || !plan.directoryRemoval) {
return null;
}
if (
shouldPreserveReplacedInstallPath({
removalTarget: plan.directoryRemoval.target,
nextInstallPath,
})
) {
return null;
}
return plan.directoryRemoval;
}
export async function persistPluginInstall(params: {
snapshot: ConfigSnapshotForInstallPersist;
pluginId: string;
install: Omit<PluginInstallUpdate, "pluginId">;
enable?: boolean;
successMessage?: string;
warningMessage?: string;
runtime?: RuntimeEnv;
}): Promise<OpenClawConfig> {
const runtime = params.runtime ?? defaultRuntime;
const installConfig =
params.enable === false
? params.snapshot.config
: removeInstalledPluginFromDenylist(
addInstalledPluginToAllowlist(params.snapshot.config, params.pluginId),
params.pluginId,
);
let next =
params.enable === false
? installConfig
: enablePluginInConfig(installConfig, params.pluginId, {
updateChannelConfig: false,
}).config;
const installRecords = await tracePluginLifecyclePhaseAsync(
"install records load",
() => loadInstalledPluginIndexInstallRecords(),
{ command: "install" },
);
const replacedInstallRemoval = resolveReplacedManagedInstallRemoval({
pluginId: params.pluginId,
previousInstall: installRecords[params.pluginId],
nextInstall: params.install,
});
const nextInstallRecords = recordPluginInstallInRecords(installRecords, {
pluginId: params.pluginId,
...params.install,
});
const slotResult =
params.enable === false
? { config: next, warnings: [] }
: await tracePluginLifecyclePhaseAsync(
"slot selection",
async () => applySlotSelectionForPlugin(next, params.pluginId),
{ command: "install", pluginId: params.pluginId },
);
next = withoutPluginInstallRecords(slotResult.config);
await tracePluginLifecyclePhaseAsync(
"config mutation",
() =>
commitPluginInstallRecordsWithConfig({
previousInstallRecords: installRecords,
nextInstallRecords,
nextConfig: next,
baseHash: params.snapshot.baseHash,
writeOptions: {
afterWrite: { mode: "restart", reason: "plugin source changed" },
},
}),
{ command: "install" },
);
if (replacedInstallRemoval) {
const removalResult = await tracePluginLifecyclePhaseAsync(
"replaced install cleanup",
() => applyPluginUninstallDirectoryRemoval(replacedInstallRemoval),
{ command: "install", pluginId: params.pluginId },
);
for (const warning of removalResult.warnings) {
runtime.log(theme.warn(warning));
}
if (removalResult.directoryRemoved) {
runtime.log(
theme.muted(
`Removed previous plugin install directory: ${shortenHomePath(replacedInstallRemoval.target)}`,
),
);
}
}
await refreshPluginRegistryAfterConfigMutation({
config: next,
reason: "source-changed",
installRecords: nextInstallRecords,
traceCommand: "install",
logger: {
warn: (message) => runtime.log(theme.warn(message)),
},
});
logSlotWarnings(slotResult.warnings, runtime);
if (params.warningMessage) {
runtime.log(theme.warn(params.warningMessage));
}
runtime.log(params.successMessage ?? `Installed plugin: ${params.pluginId}`);
logShadowedNpmInstallWarning({
config: next,
pluginId: params.pluginId,
install: params.install,
runtime,
});
runtime.log("Restart the gateway to load plugins.");
return next;
}
export async function persistHookPackInstall(params: {
snapshot: ConfigSnapshotForInstallPersist;
hookPackId: string;
hooks: string[];
install: Omit<HookInstallUpdate, "hookId" | "hooks">;
successMessage?: string;
runtime?: RuntimeEnv;
}): Promise<OpenClawConfig> {
const runtime = params.runtime ?? defaultRuntime;
let next = enableInternalHookEntries(params.snapshot.config, params.hooks);
next = recordHookInstall(next, {
hookId: params.hookPackId,
hooks: params.hooks,
...params.install,
});
await replaceConfigFile({
nextConfig: next,
baseHash: params.snapshot.baseHash,
});
runtime.log(params.successMessage ?? `Installed hook pack: ${params.hookPackId}`);
logHookPackRestartHint(runtime);
return next;
}