From c85bd2646a2d2511ebdfa259d1358b3198daa716 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 3 Mar 2026 02:51:11 +0000 Subject: [PATCH] refactor(cli): extract plugin install plan helper --- src/cli/plugin-install-plan.test.ts | 67 +++++++++++++++++++++++++++++ src/cli/plugin-install-plan.ts | 54 +++++++++++++++++++++++ src/cli/plugins-cli.ts | 47 +++++++++----------- 3 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 src/cli/plugin-install-plan.test.ts create mode 100644 src/cli/plugin-install-plan.ts diff --git a/src/cli/plugin-install-plan.test.ts b/src/cli/plugin-install-plan.test.ts new file mode 100644 index 00000000000..b81ef764298 --- /dev/null +++ b/src/cli/plugin-install-plan.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { PLUGIN_INSTALL_ERROR_CODE } from "../plugins/install.js"; +import { + resolveBundledInstallPlanBeforeNpm, + resolveBundledInstallPlanForNpmFailure, +} from "./plugin-install-plan.js"; + +describe("plugin install plan helpers", () => { + it("prefers bundled plugin for bare plugin-id specs", () => { + const findBundledSource = vi.fn().mockReturnValue({ + pluginId: "voice-call", + localPath: "/tmp/extensions/voice-call", + npmSpec: "@openclaw/voice-call", + }); + + const result = resolveBundledInstallPlanBeforeNpm({ + rawSpec: "voice-call", + findBundledSource, + }); + + expect(findBundledSource).toHaveBeenCalledWith({ kind: "pluginId", value: "voice-call" }); + expect(result?.bundledSource.pluginId).toBe("voice-call"); + expect(result?.warning).toContain('bare install spec "voice-call"'); + }); + + it("skips bundled pre-plan for scoped npm specs", () => { + const findBundledSource = vi.fn(); + const result = resolveBundledInstallPlanBeforeNpm({ + rawSpec: "@openclaw/voice-call", + findBundledSource, + }); + + expect(findBundledSource).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it("uses npm-spec bundled fallback only for package-not-found", () => { + const findBundledSource = vi.fn().mockReturnValue({ + pluginId: "voice-call", + localPath: "/tmp/extensions/voice-call", + npmSpec: "@openclaw/voice-call", + }); + const result = resolveBundledInstallPlanForNpmFailure({ + rawSpec: "@openclaw/voice-call", + code: PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND, + findBundledSource, + }); + + expect(findBundledSource).toHaveBeenCalledWith({ + kind: "npmSpec", + value: "@openclaw/voice-call", + }); + expect(result?.warning).toContain("npm package unavailable"); + }); + + it("skips fallback for non-not-found npm failures", () => { + const findBundledSource = vi.fn(); + const result = resolveBundledInstallPlanForNpmFailure({ + rawSpec: "@openclaw/voice-call", + code: "INSTALL_FAILED", + findBundledSource, + }); + + expect(findBundledSource).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); +}); diff --git a/src/cli/plugin-install-plan.ts b/src/cli/plugin-install-plan.ts new file mode 100644 index 00000000000..fbb399a48cb --- /dev/null +++ b/src/cli/plugin-install-plan.ts @@ -0,0 +1,54 @@ +import type { BundledPluginSource } from "../plugins/bundled-sources.js"; +import { PLUGIN_INSTALL_ERROR_CODE } from "../plugins/install.js"; +import { shortenHomePath } from "../utils.js"; + +type BundledLookup = (params: { + kind: "pluginId" | "npmSpec"; + value: string; +}) => BundledPluginSource | undefined; + +function isBareNpmPackageName(spec: string): boolean { + const trimmed = spec.trim(); + return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed); +} + +export function resolveBundledInstallPlanBeforeNpm(params: { + rawSpec: string; + findBundledSource: BundledLookup; +}): { bundledSource: BundledPluginSource; warning: string } | null { + if (!isBareNpmPackageName(params.rawSpec)) { + return null; + } + const bundledSource = params.findBundledSource({ + kind: "pluginId", + value: params.rawSpec, + }); + if (!bundledSource) { + return null; + } + return { + bundledSource, + warning: `Using bundled plugin "${bundledSource.pluginId}" from ${shortenHomePath(bundledSource.localPath)} for bare install spec "${params.rawSpec}". To install an npm package with the same name, use a scoped package name (for example @scope/${params.rawSpec}).`, + }; +} + +export function resolveBundledInstallPlanForNpmFailure(params: { + rawSpec: string; + code?: string; + findBundledSource: BundledLookup; +}): { bundledSource: BundledPluginSource; warning: string } | null { + if (params.code !== PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND) { + return null; + } + const bundledSource = params.findBundledSource({ + kind: "npmSpec", + value: params.rawSpec, + }); + if (!bundledSource) { + return null; + } + return { + bundledSource, + warning: `npm package unavailable for ${params.rawSpec}; using bundled plugin at ${shortenHomePath(bundledSource.localPath)}.`, + }; +} diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 67b65d903e5..36e198c71a2 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -8,11 +8,7 @@ import { resolveStateDir } from "../config/paths.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { enablePluginInConfig } from "../plugins/enable.js"; -import { - installPluginFromNpmSpec, - installPluginFromPath, - PLUGIN_INSTALL_ERROR_CODE, -} from "../plugins/install.js"; +import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; import { recordPluginInstall } from "../plugins/installs.js"; import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import type { PluginRecord } from "../plugins/registry.js"; @@ -28,6 +24,10 @@ 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 { + resolveBundledInstallPlanBeforeNpm, + resolveBundledInstallPlanForNpmFailure, +} from "./plugin-install-plan.js"; import { setPluginEnabledInConfig } from "./plugins-config.js"; import { promptYesNo } from "./prompt.js"; @@ -153,11 +153,6 @@ function logSlotWarnings(warnings: string[]) { } } -function isBareNpmPackageName(spec: string): boolean { - const trimmed = spec.trim(); - return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed); -} - async function installBundledPluginSource(params: { config: OpenClawConfig; rawSpec: string; @@ -305,17 +300,16 @@ async function runPluginInstallCommand(params: { process.exit(1); } - const bundledByPluginId = isBareNpmPackageName(raw) - ? findBundledPluginSource({ - lookup: { kind: "pluginId", value: raw }, - }) - : undefined; - if (bundledByPluginId) { + const bundledPreNpmPlan = resolveBundledInstallPlanBeforeNpm({ + rawSpec: raw, + findBundledSource: (lookup) => findBundledPluginSource({ lookup }), + }); + if (bundledPreNpmPlan) { await installBundledPluginSource({ config: cfg, rawSpec: raw, - bundledSource: bundledByPluginId, - warning: `Using bundled plugin "${bundledByPluginId.pluginId}" from ${shortenHomePath(bundledByPluginId.localPath)} for bare install spec "${raw}". To install an npm package with the same name, use a scoped package name (for example @scope/${raw}).`, + bundledSource: bundledPreNpmPlan.bundledSource, + warning: bundledPreNpmPlan.warning, }); return; } @@ -325,13 +319,12 @@ async function runPluginInstallCommand(params: { logger: createPluginInstallLogger(), }); if (!result.ok) { - const bundledFallback = - result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND - ? findBundledPluginSource({ - lookup: { kind: "npmSpec", value: raw }, - }) - : undefined; - if (!bundledFallback) { + const bundledFallbackPlan = resolveBundledInstallPlanForNpmFailure({ + rawSpec: raw, + code: result.code, + findBundledSource: (lookup) => findBundledPluginSource({ lookup }), + }); + if (!bundledFallbackPlan) { defaultRuntime.error(result.error); process.exit(1); } @@ -339,8 +332,8 @@ async function runPluginInstallCommand(params: { await installBundledPluginSource({ config: cfg, rawSpec: raw, - bundledSource: bundledFallback, - warning: `npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`, + bundledSource: bundledFallbackPlan.bundledSource, + warning: bundledFallbackPlan.warning, }); return; }