Files
openclaw/src/cli/plugins-command-helpers.ts
2026-05-06 09:16:49 +01:00

239 lines
7.2 KiB
TypeScript

import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginKind } from "../plugins/plugin-kind.types.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import { buildPluginDiagnosticsReport } from "../plugins/status.js";
import type { PluginLogger } from "../plugins/types.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { theme } from "../terminal/theme.js";
type HookInternalEntryLike = Record<string, unknown> & { enabled?: boolean };
export const quietPluginJsonLogger: PluginLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
type SlotSelectionPlugin = {
id: string;
kind?: PluginKind | PluginKind[];
};
type SlotSelectionRegistry = {
plugins: SlotSelectionPlugin[];
};
function mergeRuntimeKinds(
report: SlotSelectionRegistry,
runtimeReport: SlotSelectionRegistry,
): SlotSelectionRegistry {
const runtimeKinds = new Map(
runtimeReport.plugins
.filter((plugin) => plugin.kind)
.map((plugin) => [plugin.id, plugin.kind] as const),
);
return {
plugins: report.plugins.map((plugin) => {
if (plugin.kind) {
return plugin;
}
const runtimeKind = runtimeKinds.get(plugin.id);
return runtimeKind ? { ...plugin, kind: runtimeKind } : plugin;
}),
};
}
function loadRuntimeKindReportForPlugins(config: OpenClawConfig, pluginIds: readonly string[]) {
return buildPluginDiagnosticsReport({
config,
onlyPluginIds: [...pluginIds],
});
}
function buildSlotSelectionRegistry(
config: OpenClawConfig,
pluginId: string,
): SlotSelectionRegistry {
const plugins = loadPluginMetadataSnapshot({
config,
env: process.env,
}).plugins.filter((plugin) => plugin.id === pluginId);
return {
plugins: plugins.map((plugin) => ({
id: plugin.id,
kind: plugin.kind,
})),
};
}
export function resolveFileNpmSpecToLocalPath(
raw: string,
): { ok: true; path: string } | { ok: false; error: string } | null {
const trimmed = raw.trim();
if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("file:")) {
return null;
}
const rest = trimmed.slice("file:".length);
if (!rest) {
return { ok: false, error: "unsupported file: spec: missing path" };
}
if (rest.startsWith("///")) {
return { ok: true, path: rest.slice(2) };
}
if (rest.startsWith("//localhost/")) {
return { ok: true, path: rest.slice("//localhost".length) };
}
if (rest.startsWith("//")) {
return {
ok: false,
error: 'unsupported file: URL host (expected "file:<path>" or "file:///abs/path")',
};
}
return { ok: true, path: rest };
}
export function applySlotSelectionForPlugin(
config: OpenClawConfig,
pluginId: string,
): { config: OpenClawConfig; warnings: string[] } {
const report = buildSlotSelectionRegistry(config, pluginId);
const plugin = report.plugins.find((entry) => entry.id === pluginId);
if (!plugin) {
return { config, warnings: [] };
}
if (!plugin.kind) {
const runtimeReport = loadRuntimeKindReportForPlugins(config, [plugin.id]);
const runtimePlugin = runtimeReport.plugins.find((entry) => entry.id === plugin.id);
if (runtimePlugin?.kind) {
const result = applyExclusiveSlotSelection({
config,
selectedId: runtimePlugin.id,
selectedKind: runtimePlugin.kind,
registry: mergeRuntimeKinds(report, runtimeReport),
});
return { config: result.config, warnings: result.warnings };
}
}
const result = applyExclusiveSlotSelection({
config,
selectedId: plugin.id,
selectedKind: plugin.kind,
registry: report,
});
return { config: result.config, warnings: result.warnings };
}
export function createPluginInstallLogger(runtime: RuntimeEnv = defaultRuntime): {
info: (msg: string) => void;
warn: (msg: string) => void;
} {
return {
info: (msg) => runtime.log(msg),
warn: (msg) => runtime.log(theme.warn(msg)),
};
}
export function createHookPackInstallLogger(runtime: RuntimeEnv = defaultRuntime): {
info: (msg: string) => void;
warn: (msg: string) => void;
} {
return {
info: (msg) => runtime.log(msg),
warn: (msg) => runtime.log(theme.warn(msg)),
};
}
export function enableInternalHookEntries(
config: OpenClawConfig,
hookNames: string[],
): OpenClawConfig {
const entries = { ...config.hooks?.internal?.entries } as Record<string, HookInternalEntryLike>;
for (const hookName of hookNames) {
entries[hookName] = {
...entries[hookName],
enabled: true,
};
}
return {
...config,
hooks: {
...config.hooks,
internal: {
...config.hooks?.internal,
enabled: true,
entries,
},
},
};
}
export function formatPluginInstallWithHookFallbackError(
pluginError: string,
hookError: string,
): string {
const formattedPluginError = formatPluginInstallAttemptError(pluginError);
const formattedHookError = formatPluginInstallAttemptError(hookError);
if (/plugin already exists: .+ \(delete it first\)/.test(pluginError)) {
return `${formattedPluginError}\nUse \`openclaw plugins update <id-or-npm-spec>\` to upgrade the tracked plugin, or rerun install with \`--force\` to replace it.`;
}
if (
pluginError.startsWith("Invalid extensions directory:") ||
pluginError === "Invalid path: must stay within extensions directory"
) {
return formattedPluginError;
}
return `${formattedPluginError}\nAlso not a valid hook pack: ${formattedHookError}`;
}
const MISSING_GIT_FOR_NPM_DEPENDENCY_HINT =
"Git is required because one of this plugin's npm dependencies is fetched from a git URL, but `git` was not found on PATH. Install Git and rerun the install. On Windows, use `winget install --id Git.Git -e` or add a portable Git `bin` directory to PATH.";
function formatPluginInstallAttemptError(error: string): string {
if (!isMissingGitForNpmDependencyError(error)) {
return error;
}
if (error.includes(MISSING_GIT_FOR_NPM_DEPENDENCY_HINT)) {
return error;
}
return `${error}\n\n${MISSING_GIT_FOR_NPM_DEPENDENCY_HINT}`;
}
function isMissingGitForNpmDependencyError(error: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(error);
return /\bspawn\s+git\b/u.test(normalized) && /\benoent\b/u.test(normalized);
}
export function logHookPackRestartHint(runtime: RuntimeEnv = defaultRuntime) {
runtime.log("Restart the gateway to load hooks.");
}
export function logSlotWarnings(warnings: string[], runtime: RuntimeEnv = defaultRuntime) {
if (warnings.length === 0) {
return;
}
for (const warning of warnings) {
runtime.log(theme.warn(warning));
}
}
export function parseNpmPrefixSpec(raw: string): string | null {
const trimmed = raw.trim();
if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("npm:")) {
return null;
}
return trimmed.slice("npm:".length).trim();
}
export function parseNpmPackPrefixPath(raw: string): string | null {
const trimmed = raw.trim();
if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("npm-pack:")) {
return null;
}
return trimmed.slice("npm-pack:".length).trim();
}