diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index dc8f5220af5..7b974c95987 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -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; diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 61045065f41..0129b0c463b 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -1,35 +1,14 @@ -import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { Command } from "commander"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; -import type { HookInstallRecord } from "../config/types.hooks.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; -import { - installHooksFromNpmSpec, - installHooksFromPath, - resolveHookInstallDir, -} from "../hooks/install.js"; -import { recordHookInstall } from "../hooks/installs.js"; -import { resolveArchiveKind } from "../infra/archive.js"; -import { parseClawHubPluginSpec } from "../infra/clawhub.js"; -import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.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, - listMarketplacePlugins, - resolveMarketplaceInstallShortcut, -} from "../plugins/marketplace.js"; +import { listMarketplacePlugins } from "../plugins/marketplace.js"; import type { PluginRecord } from "../plugins/registry.js"; -import { applyExclusiveSlotSelection } from "../plugins/slots.js"; -import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; +import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js"; import { buildAllPluginInspectReports, buildPluginCompatibilityNotices, @@ -38,19 +17,19 @@ import { formatPluginCompatibilityNotice, } from "../plugins/status.js"; import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uninstall.js"; -import { updateNpmInstalledPlugins } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; -import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js"; -import { looksLikeLocalInstallSpec } from "./install-spec.js"; -import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js"; +import { shortenHomeInString, shortenHomePath } from "../utils.js"; import { - resolveBundledInstallPlanBeforeNpm, - resolveBundledInstallPlanForNpmFailure, -} from "./plugin-install-plan.js"; + applySlotSelectionForPlugin, + createPluginInstallLogger, + logSlotWarnings, +} from "./plugins-command-helpers.js"; import { setPluginEnabledInConfig } from "./plugins-config.js"; +import { runPluginInstallCommand } from "./plugins-install-command.js"; +import { runPluginUpdateCommand } from "./plugins-update-command.js"; import { promptYesNo } from "./prompt.js"; export type PluginsListOptions = { @@ -80,50 +59,6 @@ export type PluginUninstallOptions = { dryRun?: boolean; }; -type HookInternalEntryLike = Record & { enabled?: boolean }; - -type HookPackUpdateOutcome = { - hookId: string; - status: "updated" | "unchanged" | "skipped" | "error"; - message: string; - currentVersion?: string; - nextVersion?: string; -}; - -type HookPackUpdateSummary = { - config: OpenClawConfig; - changed: boolean; - outcomes: HookPackUpdateOutcome[]; -}; - -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("///")) { - // file:///abs/path -> /abs/path - return { ok: true, path: rest.slice(2) }; - } - if (rest.startsWith("//localhost/")) { - // file://localhost/abs/path -> /abs/path - return { ok: true, path: rest.slice("//localhost".length) }; - } - if (rest.startsWith("//")) { - return { - ok: false, - error: 'unsupported file: URL host (expected "file:" or "file:///abs/path")', - }; - } - return { ok: true, path: rest }; -} - function formatPluginLine(plugin: PluginRecord, verbose = false): string { const status = plugin.status === "loaded" @@ -228,926 +163,6 @@ function formatInstallLines(install: PluginInstallRecord | undefined): string[] return lines; } -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 }; -} - -function createPluginInstallLogger(): { info: (msg: string) => void; warn: (msg: string) => void } { - return { - info: (msg) => defaultRuntime.log(msg), - warn: (msg) => defaultRuntime.log(theme.warn(msg)), - }; -} - -function createHookPackInstallLogger(): { - info: (msg: string) => void; - warn: (msg: string) => void; -} { - return { - info: (msg) => defaultRuntime.log(msg), - warn: (msg) => defaultRuntime.log(theme.warn(msg)), - }; -} - -function enableInternalHookEntries(config: OpenClawConfig, hookNames: string[]): OpenClawConfig { - const entries = { ...config.hooks?.internal?.entries } as Record; - - for (const hookName of hookNames) { - entries[hookName] = { - ...entries[hookName], - enabled: true, - }; - } - - return { - ...config, - hooks: { - ...config.hooks, - internal: { - ...config.hooks?.internal, - enabled: true, - entries, - }, - }, - }; -} - -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) - ); -} - -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) - ); -} - -function resolvePluginUpdateSelection(params: { - installs: Record; - rawId?: string; - all?: boolean; -}): { pluginIds: string[]; specOverrides?: Record } { - 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; - rawId?: string; - all?: boolean; -}): { hookIds: string[]; specOverrides?: Record } { - 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, - }, - }; -} - -function formatPluginInstallWithHookFallbackError(pluginError: string, hookError: string): string { - return `${pluginError}\nAlso not a valid hook pack: ${hookError}`; -} - -function logHookPackRestartHint() { - defaultRuntime.log("Restart the gateway to load hooks."); -} - -async function readInstalledPackageVersion(dir: string): Promise { - 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; - } -} - -function logSlotWarnings(warnings: string[]) { - if (warnings.length === 0) { - return; - } - for (const warning of warnings) { - defaultRuntime.log(theme.warn(warning)); - } -} - -function buildPreferredClawHubSpec(raw: string): string | null { - const parsed = parseRegistryNpmSpec(raw); - if (!parsed) { - return null; - } - return formatClawHubSpecifier({ - name: parsed.name, - version: parsed.selector, - }); -} - -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) - ); -} -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 }; -} - -async function updateTrackedHookPacks(params: { - config: OpenClawConfig; - hookIds?: string[]; - dryRun?: boolean; - specOverrides?: Record; -}): Promise { - 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 }; -} -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.`); -} - -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); - } - // Plugin CLI registrars may have warmed the manifest registry cache before install; - // force a rescan so config validation sees the freshly installed plugin. - 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; - } - // Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup. - 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.`); -} - -export async function runPluginUpdateCommand(params: { id?: string; opts: PluginUpdateOptions }) { - 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."); - } -} - export function registerPluginsCli(program: Command) { const plugins = program .command("plugins") @@ -1537,11 +552,8 @@ export function registerPluginsCli(program: Command) { defaultRuntime.log(theme.warn("`--keep-config` is deprecated, use `--keep-files`.")); } - // Find plugin by id or name const plugin = report.plugins.find((p) => p.id === id || p.name === id); const pluginId = plugin?.id ?? id; - - // Check if plugin exists in config const hasEntry = pluginId in (cfg.plugins?.entries ?? {}); const hasInstall = pluginId in (cfg.plugins?.installs ?? {}); @@ -1558,8 +570,6 @@ export function registerPluginsCli(program: Command) { const install = cfg.plugins?.installs?.[pluginId]; const isLinked = install?.source === "path"; - - // Build preview of what will be removed const preview: string[] = []; if (hasEntry) { preview.push("config entry"); diff --git a/src/cli/plugins-command-helpers.ts b/src/cli/plugins-command-helpers.ts new file mode 100644 index 00000000000..0e91f23dda8 --- /dev/null +++ b/src/cli/plugins-command-helpers.ts @@ -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 & { 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:" 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; + + 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 { + 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) + ); +} diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts new file mode 100644 index 00000000000..73db15c9b7c --- /dev/null +++ b/src/cli/plugins-install-command.ts @@ -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."); +} diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts new file mode 100644 index 00000000000..68808c26615 --- /dev/null +++ b/src/cli/plugins-update-command.ts @@ -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; + rawId?: string; + all?: boolean; +}): { pluginIds: string[]; specOverrides?: Record } { + 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; + rawId?: string; + all?: boolean; +}): { hookIds: string[]; specOverrides?: Record } { + 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; +}): Promise { + 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."); + } +}