openclaw/src/cli/plugins-install-command.ts

493 lines
14 KiB
TypeScript

import fs from "node:fs";
import { cleanStaleMatrixPluginConfig } from "../commands/doctor/providers/matrix.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig, readBestEffortConfig } from "../config/config.js";
import { installHooksFromNpmSpec, installHooksFromPath } from "../hooks/install.js";
import { resolveArchiveKind } from "../infra/archive.js";
import { parseClawHubPluginSpec } from "../infra/clawhub.js";
import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js";
import { formatClawHubSpecifier, installPluginFromClawHub } from "../plugins/clawhub.js";
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
import {
installPluginFromMarketplace,
resolveMarketplaceInstallShortcut,
} from "../plugins/marketplace.js";
import { defaultRuntime } from "../runtime.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { looksLikeLocalInstallSpec } from "./install-spec.js";
import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js";
import {
resolveBundledInstallPlanBeforeNpm,
resolveBundledInstallPlanForNpmFailure,
} from "./plugin-install-plan.js";
import {
buildPreferredClawHubSpec,
createHookPackInstallLogger,
createPluginInstallLogger,
decidePreferredClawHubFallback,
formatPluginInstallWithHookFallbackError,
resolveFileNpmSpecToLocalPath,
} from "./plugins-command-helpers.js";
import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js";
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]));
await persistPluginInstall({
config: {
...params.config,
plugins: {
...params.config.plugins,
load: {
...params.config.plugins?.load,
paths: mergedPaths,
},
},
},
pluginId: params.bundledSource.pluginId,
install: {
source: "path",
spec: params.rawSpec,
sourcePath: params.bundledSource.localPath,
installPath: params.bundledSource.localPath,
},
warningMessage: params.warning,
});
}
async function tryInstallHookPackFromLocalPath(params: {
config: OpenClawConfig;
resolvedPath: string;
link?: boolean;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (params.link) {
const stat = fs.statSync(params.resolvedPath);
if (!stat.isDirectory()) {
return {
ok: false,
error: "Linked hook pack paths must be directories.",
};
}
const probe = await installHooksFromPath({
path: params.resolvedPath,
dryRun: true,
});
if (!probe.ok) {
return probe;
}
const existing = params.config.hooks?.internal?.load?.extraDirs ?? [];
const merged = Array.from(new Set([...existing, params.resolvedPath]));
await persistHookPackInstall({
config: {
...params.config,
hooks: {
...params.config.hooks,
internal: {
...params.config.hooks?.internal,
enabled: true,
load: {
...params.config.hooks?.internal?.load,
extraDirs: merged,
},
},
},
},
hookPackId: probe.hookPackId,
hooks: probe.hooks,
install: {
source: "path",
sourcePath: params.resolvedPath,
installPath: params.resolvedPath,
version: probe.version,
},
successMessage: `Linked hook pack path: ${shortenHomePath(params.resolvedPath)}`,
});
return { ok: true };
}
const result = await installHooksFromPath({
path: params.resolvedPath,
logger: createHookPackInstallLogger(),
});
if (!result.ok) {
return result;
}
const source: "archive" | "path" = resolveArchiveKind(params.resolvedPath) ? "archive" : "path";
await persistHookPackInstall({
config: params.config,
hookPackId: result.hookPackId,
hooks: result.hooks,
install: {
source,
sourcePath: params.resolvedPath,
installPath: result.targetDir,
version: result.version,
},
});
return { ok: true };
}
async function tryInstallHookPackFromNpmSpec(params: {
config: OpenClawConfig;
spec: string;
pin?: boolean;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const result = await installHooksFromNpmSpec({
spec: params.spec,
logger: createHookPackInstallLogger(),
});
if (!result.ok) {
return result;
}
const installRecord = resolvePinnedNpmInstallRecordForCli(
params.spec,
Boolean(params.pin),
result.targetDir,
result.version,
result.npmResolution,
defaultRuntime.log,
theme.warn,
);
await persistHookPackInstall({
config: params.config,
hookPackId: result.hookPackId,
hooks: result.hooks,
install: installRecord,
});
return { ok: true };
}
// loadConfig() throws when config is invalid; fall back to best-effort so
// repair-oriented installs (e.g. reinstalling a broken Matrix plugin) can
// still proceed from a partially valid config snapshot.
// Only catch config-validation errors — real failures (fs permission, OOM)
// must surface so the user sees the actual problem.
// After loading, clean any stale Matrix plugin references so that
// persistPluginInstall() → writeConfigFile() does not fail validation
// on paths that no longer exist (#52899 concern 4).
async function loadConfigForInstall(): Promise<OpenClawConfig> {
let cfg: OpenClawConfig;
try {
cfg = loadConfig();
} catch (err) {
if (isConfigValidationError(err)) {
cfg = await readBestEffortConfig();
} else {
throw err;
}
}
const cleaned = await cleanStaleMatrixPluginConfig(cfg);
return cleaned.config;
}
function isConfigValidationError(err: unknown): boolean {
return err instanceof Error && (err as { code?: string }).code === "INVALID_CONFIG";
}
export async function runPluginInstallCommand(params: {
raw: string;
opts: { link?: boolean; pin?: boolean; marketplace?: string };
}) {
const shorthand = !params.opts.marketplace
? await resolveMarketplaceInstallShortcut(params.raw)
: null;
if (shorthand?.ok === false) {
defaultRuntime.error(shorthand.error);
return defaultRuntime.exit(1);
}
const raw = shorthand?.ok ? shorthand.plugin : params.raw;
const opts = {
...params.opts,
marketplace:
params.opts.marketplace ?? (shorthand?.ok ? shorthand.marketplaceSource : undefined),
};
if (opts.marketplace) {
if (opts.link) {
defaultRuntime.error("`--link` is not supported with `--marketplace`.");
return defaultRuntime.exit(1);
}
if (opts.pin) {
defaultRuntime.error("`--pin` is not supported with `--marketplace`.");
return defaultRuntime.exit(1);
}
const cfg = await loadConfigForInstall();
const result = await installPluginFromMarketplace({
marketplace: opts.marketplace,
plugin: raw,
logger: createPluginInstallLogger(),
});
if (!result.ok) {
defaultRuntime.error(result.error);
return defaultRuntime.exit(1);
}
clearPluginManifestRegistryCache();
await persistPluginInstall({
config: cfg,
pluginId: result.pluginId,
install: {
source: "marketplace",
installPath: result.targetDir,
version: result.version,
marketplaceName: result.marketplaceName,
marketplaceSource: result.marketplaceSource,
marketplacePlugin: result.marketplacePlugin,
},
});
return;
}
const fileSpec = resolveFileNpmSpecToLocalPath(raw);
if (fileSpec && !fileSpec.ok) {
defaultRuntime.error(fileSpec.error);
return defaultRuntime.exit(1);
}
const normalized = fileSpec && fileSpec.ok ? fileSpec.path : raw;
const resolved = resolveUserPath(normalized);
const cfg = await loadConfigForInstall();
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) {
const hookFallback = await tryInstallHookPackFromLocalPath({
config: cfg,
resolvedPath: resolved,
link: true,
});
if (hookFallback.ok) {
return;
}
defaultRuntime.error(
formatPluginInstallWithHookFallbackError(probe.error, hookFallback.error),
);
return defaultRuntime.exit(1);
}
await persistPluginInstall({
config: {
...cfg,
plugins: {
...cfg.plugins,
load: {
...cfg.plugins?.load,
paths: merged,
},
},
},
pluginId: probe.pluginId,
install: {
source: "path",
sourcePath: resolved,
installPath: resolved,
version: probe.version,
},
successMessage: `Linked plugin path: ${shortenHomePath(resolved)}`,
});
return;
}
const result = await installPluginFromPath({
path: resolved,
logger: createPluginInstallLogger(),
});
if (!result.ok) {
const hookFallback = await tryInstallHookPackFromLocalPath({
config: cfg,
resolvedPath: resolved,
});
if (hookFallback.ok) {
return;
}
defaultRuntime.error(
formatPluginInstallWithHookFallbackError(result.error, hookFallback.error),
);
return defaultRuntime.exit(1);
}
clearPluginManifestRegistryCache();
const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path";
await persistPluginInstall({
config: cfg,
pluginId: result.pluginId,
install: {
source,
sourcePath: resolved,
installPath: result.targetDir,
version: result.version,
},
});
return;
}
if (opts.link) {
defaultRuntime.error("`--link` requires a local path.");
return defaultRuntime.exit(1);
}
if (
looksLikeLocalInstallSpec(raw, [
".ts",
".js",
".mjs",
".cjs",
".tgz",
".tar.gz",
".tar",
".zip",
])
) {
defaultRuntime.error(`Path not found: ${resolved}`);
return defaultRuntime.exit(1);
}
const bundledPreNpmPlan = resolveBundledInstallPlanBeforeNpm({
rawSpec: raw,
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
});
if (bundledPreNpmPlan) {
await installBundledPluginSource({
config: cfg,
rawSpec: raw,
bundledSource: bundledPreNpmPlan.bundledSource,
warning: bundledPreNpmPlan.warning,
});
return;
}
const clawhubSpec = parseClawHubPluginSpec(raw);
if (clawhubSpec) {
const result = await installPluginFromClawHub({
spec: raw,
logger: createPluginInstallLogger(),
});
if (!result.ok) {
defaultRuntime.error(result.error);
return defaultRuntime.exit(1);
}
clearPluginManifestRegistryCache();
await persistPluginInstall({
config: cfg,
pluginId: result.pluginId,
install: {
source: "clawhub",
spec: formatClawHubSpecifier({
name: result.clawhub.clawhubPackage,
version: result.clawhub.version,
}),
installPath: result.targetDir,
version: result.version,
integrity: result.clawhub.integrity,
resolvedAt: result.clawhub.resolvedAt,
clawhubUrl: result.clawhub.clawhubUrl,
clawhubPackage: result.clawhub.clawhubPackage,
clawhubFamily: result.clawhub.clawhubFamily,
clawhubChannel: result.clawhub.clawhubChannel,
},
});
return;
}
const preferredClawHubSpec = buildPreferredClawHubSpec(raw);
if (preferredClawHubSpec) {
const clawhubResult = await installPluginFromClawHub({
spec: preferredClawHubSpec,
logger: createPluginInstallLogger(),
});
if (clawhubResult.ok) {
clearPluginManifestRegistryCache();
await persistPluginInstall({
config: cfg,
pluginId: clawhubResult.pluginId,
install: {
source: "clawhub",
spec: formatClawHubSpecifier({
name: clawhubResult.clawhub.clawhubPackage,
version: clawhubResult.clawhub.version,
}),
installPath: clawhubResult.targetDir,
version: clawhubResult.version,
integrity: clawhubResult.clawhub.integrity,
resolvedAt: clawhubResult.clawhub.resolvedAt,
clawhubUrl: clawhubResult.clawhub.clawhubUrl,
clawhubPackage: clawhubResult.clawhub.clawhubPackage,
clawhubFamily: clawhubResult.clawhub.clawhubFamily,
clawhubChannel: clawhubResult.clawhub.clawhubChannel,
},
});
return;
}
if (decidePreferredClawHubFallback(clawhubResult) !== "fallback_to_npm") {
defaultRuntime.error(clawhubResult.error);
return defaultRuntime.exit(1);
}
}
const result = await installPluginFromNpmSpec({
spec: raw,
logger: createPluginInstallLogger(),
});
if (!result.ok) {
const bundledFallbackPlan = resolveBundledInstallPlanForNpmFailure({
rawSpec: raw,
code: result.code,
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
});
if (!bundledFallbackPlan) {
const hookFallback = await tryInstallHookPackFromNpmSpec({
config: cfg,
spec: raw,
pin: opts.pin,
});
if (hookFallback.ok) {
return;
}
defaultRuntime.error(
formatPluginInstallWithHookFallbackError(result.error, hookFallback.error),
);
return defaultRuntime.exit(1);
}
await installBundledPluginSource({
config: cfg,
rawSpec: raw,
bundledSource: bundledFallbackPlan.bundledSource,
warning: bundledFallbackPlan.warning,
});
return;
}
clearPluginManifestRegistryCache();
const installRecord = resolvePinnedNpmInstallRecordForCli(
raw,
Boolean(opts.pin),
result.targetDir,
result.version,
result.npmResolution,
defaultRuntime.log,
theme.warn,
);
await persistPluginInstall({
config: cfg,
pluginId: result.pluginId,
install: installRecord,
});
}