mirror of https://github.com/openclaw/openclaw.git
493 lines
14 KiB
TypeScript
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,
|
|
});
|
|
}
|