mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
refactor(cli): extract plugin install and update commands
This commit is contained in:
@@ -17,7 +17,8 @@ import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { runPluginInstallCommand, runPluginUpdateCommand } from "./plugins-cli.js";
|
||||
import { runPluginInstallCommand } from "./plugins-install-command.js";
|
||||
import { runPluginUpdateCommand } from "./plugins-update-command.js";
|
||||
|
||||
export type HooksListOptions = {
|
||||
json?: boolean;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
174
src/cli/plugins-command-helpers.ts
Normal file
174
src/cli/plugins-command-helpers.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { HookInstallRecord } from "../config/types.hooks.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
||||
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
|
||||
import { buildPluginStatusReport } from "../plugins/status.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
|
||||
type HookInternalEntryLike = Record<string, unknown> & { enabled?: boolean };
|
||||
|
||||
export function resolveFileNpmSpecToLocalPath(
|
||||
raw: string,
|
||||
): { ok: true; path: string } | { ok: false; error: string } | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().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 = buildPluginStatusReport({ config });
|
||||
const plugin = report.plugins.find((entry) => entry.id === pluginId);
|
||||
if (!plugin) {
|
||||
return { config, warnings: [] };
|
||||
}
|
||||
const result = applyExclusiveSlotSelection({
|
||||
config,
|
||||
selectedId: plugin.id,
|
||||
selectedKind: plugin.kind,
|
||||
registry: report,
|
||||
});
|
||||
return { config: result.config, warnings: result.warnings };
|
||||
}
|
||||
|
||||
export function createPluginInstallLogger(): {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
} {
|
||||
return {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
|
||||
};
|
||||
}
|
||||
|
||||
export function createHookPackInstallLogger(): {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
} {
|
||||
return {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.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 extractInstalledNpmPackageName(install: PluginInstallRecord): string | undefined {
|
||||
if (install.source !== "npm") {
|
||||
return undefined;
|
||||
}
|
||||
const resolvedName = install.resolvedName?.trim();
|
||||
if (resolvedName) {
|
||||
return resolvedName;
|
||||
}
|
||||
return (
|
||||
(install.spec ? parseRegistryNpmSpec(install.spec)?.name : undefined) ??
|
||||
(install.resolvedSpec ? parseRegistryNpmSpec(install.resolvedSpec)?.name : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export function extractInstalledNpmHookPackageName(install: HookInstallRecord): string | undefined {
|
||||
const resolvedName = install.resolvedName?.trim();
|
||||
if (resolvedName) {
|
||||
return resolvedName;
|
||||
}
|
||||
return (
|
||||
(install.spec ? parseRegistryNpmSpec(install.spec)?.name : undefined) ??
|
||||
(install.resolvedSpec ? parseRegistryNpmSpec(install.resolvedSpec)?.name : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export function formatPluginInstallWithHookFallbackError(
|
||||
pluginError: string,
|
||||
hookError: string,
|
||||
): string {
|
||||
return `${pluginError}\nAlso not a valid hook pack: ${hookError}`;
|
||||
}
|
||||
|
||||
export function logHookPackRestartHint() {
|
||||
defaultRuntime.log("Restart the gateway to load hooks.");
|
||||
}
|
||||
|
||||
export async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(dir, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { version?: unknown };
|
||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function logSlotWarnings(warnings: string[]) {
|
||||
if (warnings.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const warning of warnings) {
|
||||
defaultRuntime.log(theme.warn(warning));
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPreferredClawHubSpec(raw: string): string | null {
|
||||
const parsed = parseRegistryNpmSpec(raw);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return `clawhub:${parsed.name}${parsed.selector ? `@${parsed.selector}` : ""}`;
|
||||
}
|
||||
|
||||
export function shouldFallbackFromClawHubToNpm(error: string): boolean {
|
||||
const normalized = error.trim();
|
||||
return (
|
||||
/Package not found on ClawHub/i.test(normalized) ||
|
||||
/ClawHub .* failed \(404\)/i.test(normalized) ||
|
||||
/Version not found/i.test(normalized)
|
||||
);
|
||||
}
|
||||
519
src/cli/plugins-install-command.ts
Normal file
519
src/cli/plugins-install-command.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
import fs from "node:fs";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
import { installHooksFromNpmSpec, installHooksFromPath } from "../hooks/install.js";
|
||||
import { recordHookInstall } from "../hooks/installs.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 { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
|
||||
import { recordPluginInstall } from "../plugins/installs.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 {
|
||||
applySlotSelectionForPlugin,
|
||||
buildPreferredClawHubSpec,
|
||||
createHookPackInstallLogger,
|
||||
createPluginInstallLogger,
|
||||
enableInternalHookEntries,
|
||||
formatPluginInstallWithHookFallbackError,
|
||||
logHookPackRestartHint,
|
||||
logSlotWarnings,
|
||||
resolveFileNpmSpecToLocalPath,
|
||||
shouldFallbackFromClawHubToNpm,
|
||||
} from "./plugins-command-helpers.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]));
|
||||
let next: OpenClawConfig = {
|
||||
...params.config,
|
||||
plugins: {
|
||||
...params.config.plugins,
|
||||
load: {
|
||||
...params.config.plugins?.load,
|
||||
paths: mergedPaths,
|
||||
},
|
||||
entries: {
|
||||
...params.config.plugins?.entries,
|
||||
[params.bundledSource.pluginId]: {
|
||||
...(params.config.plugins?.entries?.[params.bundledSource.pluginId] as
|
||||
| object
|
||||
| undefined),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: params.bundledSource.pluginId,
|
||||
source: "path",
|
||||
spec: params.rawSpec,
|
||||
sourcePath: params.bundledSource.localPath,
|
||||
installPath: params.bundledSource.localPath,
|
||||
});
|
||||
const slotResult = applySlotSelectionForPlugin(next, params.bundledSource.pluginId);
|
||||
next = slotResult.config;
|
||||
await writeConfigFile(next);
|
||||
logSlotWarnings(slotResult.warnings);
|
||||
defaultRuntime.log(theme.warn(params.warning));
|
||||
defaultRuntime.log(`Installed plugin: ${params.bundledSource.pluginId}`);
|
||||
defaultRuntime.log("Restart the gateway to load plugins.");
|
||||
}
|
||||
|
||||
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]));
|
||||
let next: OpenClawConfig = {
|
||||
...params.config,
|
||||
hooks: {
|
||||
...params.config.hooks,
|
||||
internal: {
|
||||
...params.config.hooks?.internal,
|
||||
enabled: true,
|
||||
load: {
|
||||
...params.config.hooks?.internal?.load,
|
||||
extraDirs: merged,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
next = enableInternalHookEntries(next, probe.hooks);
|
||||
next = recordHookInstall(next, {
|
||||
hookId: probe.hookPackId,
|
||||
source: "path",
|
||||
sourcePath: params.resolvedPath,
|
||||
installPath: params.resolvedPath,
|
||||
version: probe.version,
|
||||
hooks: probe.hooks,
|
||||
});
|
||||
await writeConfigFile(next);
|
||||
defaultRuntime.log(`Linked hook pack path: ${shortenHomePath(params.resolvedPath)}`);
|
||||
logHookPackRestartHint();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const result = await installHooksFromPath({
|
||||
path: params.resolvedPath,
|
||||
logger: createHookPackInstallLogger(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let next = enableInternalHookEntries(params.config, result.hooks);
|
||||
const source: "archive" | "path" = resolveArchiveKind(params.resolvedPath) ? "archive" : "path";
|
||||
next = recordHookInstall(next, {
|
||||
hookId: result.hookPackId,
|
||||
source,
|
||||
sourcePath: params.resolvedPath,
|
||||
installPath: result.targetDir,
|
||||
version: result.version,
|
||||
hooks: result.hooks,
|
||||
});
|
||||
await writeConfigFile(next);
|
||||
defaultRuntime.log(`Installed hook pack: ${result.hookPackId}`);
|
||||
logHookPackRestartHint();
|
||||
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;
|
||||
}
|
||||
|
||||
let next = enableInternalHookEntries(params.config, result.hooks);
|
||||
const installRecord = resolvePinnedNpmInstallRecordForCli(
|
||||
params.spec,
|
||||
Boolean(params.pin),
|
||||
result.targetDir,
|
||||
result.version,
|
||||
result.npmResolution,
|
||||
defaultRuntime.log,
|
||||
theme.warn,
|
||||
);
|
||||
next = recordHookInstall(next, {
|
||||
hookId: result.hookPackId,
|
||||
...installRecord,
|
||||
hooks: result.hooks,
|
||||
});
|
||||
await writeConfigFile(next);
|
||||
defaultRuntime.log(`Installed hook pack: ${result.hookPackId}`);
|
||||
logHookPackRestartHint();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
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 = loadConfig();
|
||||
const result = await installPluginFromMarketplace({
|
||||
marketplace: opts.marketplace,
|
||||
plugin: raw,
|
||||
logger: createPluginInstallLogger(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
defaultRuntime.error(result.error);
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
clearPluginManifestRegistryCache();
|
||||
|
||||
let next = enablePluginInConfig(cfg, result.pluginId).config;
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: result.pluginId,
|
||||
source: "marketplace",
|
||||
installPath: result.targetDir,
|
||||
version: result.version,
|
||||
marketplaceName: result.marketplaceName,
|
||||
marketplaceSource: result.marketplaceSource,
|
||||
marketplacePlugin: result.marketplacePlugin,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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 = 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) {
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
const hookFallback = await tryInstallHookPackFromLocalPath({
|
||||
config: cfg,
|
||||
resolvedPath: resolved,
|
||||
});
|
||||
if (hookFallback.ok) {
|
||||
return;
|
||||
}
|
||||
defaultRuntime.error(
|
||||
formatPluginInstallWithHookFallbackError(result.error, hookFallback.error),
|
||||
);
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
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.");
|
||||
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();
|
||||
|
||||
let next = enablePluginInConfig(cfg, result.pluginId).config;
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: result.pluginId,
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
const preferredClawHubSpec = buildPreferredClawHubSpec(raw);
|
||||
if (preferredClawHubSpec) {
|
||||
const clawhubResult = await installPluginFromClawHub({
|
||||
spec: preferredClawHubSpec,
|
||||
logger: createPluginInstallLogger(),
|
||||
});
|
||||
if (clawhubResult.ok) {
|
||||
clearPluginManifestRegistryCache();
|
||||
|
||||
let next = enablePluginInConfig(cfg, clawhubResult.pluginId).config;
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: clawhubResult.pluginId,
|
||||
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,
|
||||
});
|
||||
const slotResult = applySlotSelectionForPlugin(next, clawhubResult.pluginId);
|
||||
next = slotResult.config;
|
||||
await writeConfigFile(next);
|
||||
logSlotWarnings(slotResult.warnings);
|
||||
defaultRuntime.log(`Installed plugin: ${clawhubResult.pluginId}`);
|
||||
defaultRuntime.log("Restart the gateway to load plugins.");
|
||||
return;
|
||||
}
|
||||
if (!shouldFallbackFromClawHubToNpm(clawhubResult.error)) {
|
||||
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();
|
||||
|
||||
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.");
|
||||
}
|
||||
342
src/cli/plugins-update-command.ts
Normal file
342
src/cli/plugins-update-command.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
import type { HookInstallRecord } from "../config/types.hooks.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { installHooksFromNpmSpec, resolveHookInstallDir } from "../hooks/install.js";
|
||||
import { recordHookInstall } from "../hooks/installs.js";
|
||||
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
||||
import { updateNpmInstalledPlugins } from "../plugins/update.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import {
|
||||
createHookPackInstallLogger,
|
||||
extractInstalledNpmHookPackageName,
|
||||
extractInstalledNpmPackageName,
|
||||
readInstalledPackageVersion,
|
||||
} from "./plugins-command-helpers.js";
|
||||
import { promptYesNo } from "./prompt.js";
|
||||
|
||||
type HookPackUpdateOutcome = {
|
||||
hookId: string;
|
||||
status: "updated" | "unchanged" | "skipped" | "error";
|
||||
message: string;
|
||||
currentVersion?: string;
|
||||
nextVersion?: string;
|
||||
};
|
||||
|
||||
type HookPackUpdateSummary = {
|
||||
config: OpenClawConfig;
|
||||
changed: boolean;
|
||||
outcomes: HookPackUpdateOutcome[];
|
||||
};
|
||||
|
||||
function resolvePluginUpdateSelection(params: {
|
||||
installs: Record<string, PluginInstallRecord>;
|
||||
rawId?: string;
|
||||
all?: boolean;
|
||||
}): { pluginIds: string[]; specOverrides?: Record<string, string> } {
|
||||
if (params.all) {
|
||||
return { pluginIds: Object.keys(params.installs) };
|
||||
}
|
||||
if (!params.rawId) {
|
||||
return { pluginIds: [] };
|
||||
}
|
||||
|
||||
const parsedSpec = parseRegistryNpmSpec(params.rawId);
|
||||
if (!parsedSpec || parsedSpec.selectorKind === "none") {
|
||||
return { pluginIds: [params.rawId] };
|
||||
}
|
||||
|
||||
const matches = Object.entries(params.installs).filter(([, install]) => {
|
||||
return extractInstalledNpmPackageName(install) === parsedSpec.name;
|
||||
});
|
||||
if (matches.length !== 1) {
|
||||
return { pluginIds: [params.rawId] };
|
||||
}
|
||||
|
||||
const [pluginId] = matches[0];
|
||||
if (!pluginId) {
|
||||
return { pluginIds: [params.rawId] };
|
||||
}
|
||||
return {
|
||||
pluginIds: [pluginId],
|
||||
specOverrides: {
|
||||
[pluginId]: parsedSpec.raw,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveHookPackUpdateSelection(params: {
|
||||
installs: Record<string, HookInstallRecord>;
|
||||
rawId?: string;
|
||||
all?: boolean;
|
||||
}): { hookIds: string[]; specOverrides?: Record<string, string> } {
|
||||
if (params.all) {
|
||||
return { hookIds: Object.keys(params.installs) };
|
||||
}
|
||||
if (!params.rawId) {
|
||||
return { hookIds: [] };
|
||||
}
|
||||
if (params.rawId in params.installs) {
|
||||
return { hookIds: [params.rawId] };
|
||||
}
|
||||
|
||||
const parsedSpec = parseRegistryNpmSpec(params.rawId);
|
||||
if (!parsedSpec || parsedSpec.selectorKind === "none") {
|
||||
return { hookIds: [] };
|
||||
}
|
||||
|
||||
const matches = Object.entries(params.installs).filter(([, install]) => {
|
||||
return extractInstalledNpmHookPackageName(install) === parsedSpec.name;
|
||||
});
|
||||
if (matches.length !== 1) {
|
||||
return { hookIds: [] };
|
||||
}
|
||||
|
||||
const [hookId] = matches[0];
|
||||
if (!hookId) {
|
||||
return { hookIds: [] };
|
||||
}
|
||||
return {
|
||||
hookIds: [hookId],
|
||||
specOverrides: {
|
||||
[hookId]: parsedSpec.raw,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function updateTrackedHookPacks(params: {
|
||||
config: OpenClawConfig;
|
||||
hookIds?: string[];
|
||||
dryRun?: boolean;
|
||||
specOverrides?: Record<string, string>;
|
||||
}): Promise<HookPackUpdateSummary> {
|
||||
const installs = params.config.hooks?.internal?.installs ?? {};
|
||||
const targets = params.hookIds?.length ? params.hookIds : Object.keys(installs);
|
||||
const outcomes: HookPackUpdateOutcome[] = [];
|
||||
let next = params.config;
|
||||
let changed = false;
|
||||
|
||||
for (const hookId of targets) {
|
||||
const record = installs[hookId];
|
||||
if (!record) {
|
||||
outcomes.push({
|
||||
hookId,
|
||||
status: "skipped",
|
||||
message: `No install record for hook pack "${hookId}".`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (record.source !== "npm") {
|
||||
outcomes.push({
|
||||
hookId,
|
||||
status: "skipped",
|
||||
message: `Skipping hook pack "${hookId}" (source: ${record.source}).`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const effectiveSpec = params.specOverrides?.[hookId] ?? record.spec;
|
||||
if (!effectiveSpec) {
|
||||
outcomes.push({
|
||||
hookId,
|
||||
status: "skipped",
|
||||
message: `Skipping hook pack "${hookId}" (missing npm spec).`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let installPath: string;
|
||||
try {
|
||||
installPath = record.installPath ?? resolveHookInstallDir(hookId);
|
||||
} catch (err) {
|
||||
outcomes.push({
|
||||
hookId,
|
||||
status: "error",
|
||||
message: `Invalid install path for hook pack "${hookId}": ${String(err)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const currentVersion = await readInstalledPackageVersion(installPath);
|
||||
|
||||
const onIntegrityDrift = async (drift: {
|
||||
spec: string;
|
||||
expectedIntegrity: string;
|
||||
actualIntegrity: string;
|
||||
resolution: { resolvedSpec?: string };
|
||||
}) => {
|
||||
const specLabel = drift.resolution.resolvedSpec ?? drift.spec;
|
||||
defaultRuntime.log(
|
||||
theme.warn(
|
||||
`Integrity drift detected for hook pack "${hookId}" (${specLabel})` +
|
||||
`\nExpected: ${drift.expectedIntegrity}` +
|
||||
`\nActual: ${drift.actualIntegrity}`,
|
||||
),
|
||||
);
|
||||
if (params.dryRun) {
|
||||
return true;
|
||||
}
|
||||
return await promptYesNo(`Continue updating hook pack "${hookId}" with this artifact?`);
|
||||
};
|
||||
|
||||
const result = params.dryRun
|
||||
? await installHooksFromNpmSpec({
|
||||
spec: effectiveSpec,
|
||||
mode: "update",
|
||||
dryRun: true,
|
||||
expectedHookPackId: hookId,
|
||||
expectedIntegrity: record.integrity,
|
||||
onIntegrityDrift,
|
||||
logger: createHookPackInstallLogger(),
|
||||
})
|
||||
: await installHooksFromNpmSpec({
|
||||
spec: effectiveSpec,
|
||||
mode: "update",
|
||||
expectedHookPackId: hookId,
|
||||
expectedIntegrity: record.integrity,
|
||||
onIntegrityDrift,
|
||||
logger: createHookPackInstallLogger(),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
outcomes.push({
|
||||
hookId,
|
||||
status: "error",
|
||||
message: `Failed to ${params.dryRun ? "check" : "update"} hook pack "${hookId}": ${result.error}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
|
||||
const currentLabel = currentVersion ?? "unknown";
|
||||
const nextLabel = nextVersion ?? "unknown";
|
||||
|
||||
if (params.dryRun) {
|
||||
outcomes.push({
|
||||
hookId,
|
||||
status:
|
||||
currentVersion && nextVersion && currentVersion === nextVersion ? "unchanged" : "updated",
|
||||
currentVersion: currentVersion ?? undefined,
|
||||
nextVersion: nextVersion ?? undefined,
|
||||
message:
|
||||
currentVersion && nextVersion && currentVersion === nextVersion
|
||||
? `Hook pack "${hookId}" is up to date (${currentLabel}).`
|
||||
: `Would update hook pack "${hookId}": ${currentLabel} -> ${nextLabel}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
next = recordHookInstall(next, {
|
||||
hookId,
|
||||
source: "npm",
|
||||
spec: effectiveSpec,
|
||||
installPath: result.targetDir,
|
||||
version: nextVersion,
|
||||
resolvedName: result.npmResolution?.name,
|
||||
resolvedSpec: result.npmResolution?.resolvedSpec,
|
||||
integrity: result.npmResolution?.integrity,
|
||||
hooks: result.hooks,
|
||||
});
|
||||
changed = true;
|
||||
|
||||
outcomes.push({
|
||||
hookId,
|
||||
status:
|
||||
currentVersion && nextVersion && currentVersion === nextVersion ? "unchanged" : "updated",
|
||||
currentVersion: currentVersion ?? undefined,
|
||||
nextVersion: nextVersion ?? undefined,
|
||||
message:
|
||||
currentVersion && nextVersion && currentVersion === nextVersion
|
||||
? `Hook pack "${hookId}" already at ${currentLabel}.`
|
||||
: `Updated hook pack "${hookId}": ${currentLabel} -> ${nextLabel}.`,
|
||||
});
|
||||
}
|
||||
|
||||
return { config: next, changed, outcomes };
|
||||
}
|
||||
|
||||
export async function runPluginUpdateCommand(params: {
|
||||
id?: string;
|
||||
opts: { all?: boolean; dryRun?: boolean };
|
||||
}) {
|
||||
const cfg = loadConfig();
|
||||
const pluginSelection = resolvePluginUpdateSelection({
|
||||
installs: cfg.plugins?.installs ?? {},
|
||||
rawId: params.id,
|
||||
all: params.opts.all,
|
||||
});
|
||||
const hookSelection = resolveHookPackUpdateSelection({
|
||||
installs: cfg.hooks?.internal?.installs ?? {},
|
||||
rawId: params.id,
|
||||
all: params.opts.all,
|
||||
});
|
||||
|
||||
if (pluginSelection.pluginIds.length === 0 && hookSelection.hookIds.length === 0) {
|
||||
if (params.opts.all) {
|
||||
defaultRuntime.log("No tracked plugins or hook packs to update.");
|
||||
return;
|
||||
}
|
||||
defaultRuntime.error("Provide a plugin or hook-pack id, or use --all.");
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
const pluginResult = await updateNpmInstalledPlugins({
|
||||
config: cfg,
|
||||
pluginIds: pluginSelection.pluginIds,
|
||||
specOverrides: pluginSelection.specOverrides,
|
||||
dryRun: params.opts.dryRun,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
|
||||
},
|
||||
onIntegrityDrift: async (drift) => {
|
||||
const specLabel = drift.resolvedSpec ?? drift.spec;
|
||||
defaultRuntime.log(
|
||||
theme.warn(
|
||||
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
|
||||
`\nExpected: ${drift.expectedIntegrity}` +
|
||||
`\nActual: ${drift.actualIntegrity}`,
|
||||
),
|
||||
);
|
||||
if (drift.dryRun) {
|
||||
return true;
|
||||
}
|
||||
return await promptYesNo(`Continue updating "${drift.pluginId}" with this artifact?`);
|
||||
},
|
||||
});
|
||||
const hookResult = await updateTrackedHookPacks({
|
||||
config: pluginResult.config,
|
||||
hookIds: hookSelection.hookIds,
|
||||
specOverrides: hookSelection.specOverrides,
|
||||
dryRun: params.opts.dryRun,
|
||||
});
|
||||
|
||||
for (const outcome of pluginResult.outcomes) {
|
||||
if (outcome.status === "error") {
|
||||
defaultRuntime.log(theme.error(outcome.message));
|
||||
continue;
|
||||
}
|
||||
if (outcome.status === "skipped") {
|
||||
defaultRuntime.log(theme.warn(outcome.message));
|
||||
continue;
|
||||
}
|
||||
defaultRuntime.log(outcome.message);
|
||||
}
|
||||
|
||||
for (const outcome of hookResult.outcomes) {
|
||||
if (outcome.status === "error") {
|
||||
defaultRuntime.log(theme.error(outcome.message));
|
||||
continue;
|
||||
}
|
||||
if (outcome.status === "skipped") {
|
||||
defaultRuntime.log(theme.warn(outcome.message));
|
||||
continue;
|
||||
}
|
||||
defaultRuntime.log(outcome.message);
|
||||
}
|
||||
|
||||
if (!params.opts.dryRun && (pluginResult.changed || hookResult.changed)) {
|
||||
await writeConfigFile(hookResult.config);
|
||||
defaultRuntime.log("Restart the gateway to load plugins and hooks.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user