diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index e75cbd59e76..714550ab1ac 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { resolveArchiveKind } from "../infra/archive.js"; +import { findBundledPluginByNpmSpec } from "../plugins/bundled-sources.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; import { recordPluginInstall } from "../plugins/installs.js"; @@ -147,6 +148,16 @@ function logSlotWarnings(warnings: string[]) { } } +function isPackageNotFoundInstallError(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes("npm pack failed:") && + (lower.includes("e404") || + lower.includes("404 not found") || + lower.includes("could not be found")) + ); +} + export function registerPluginsCli(program: Command) { const plugins = program .command("plugins") @@ -614,8 +625,52 @@ export function registerPluginsCli(program: Command) { logger: createPluginInstallLogger(), }); if (!result.ok) { - defaultRuntime.error(result.error); - process.exit(1); + const bundledFallback = isPackageNotFoundInstallError(result.error) + ? findBundledPluginByNpmSpec({ spec: raw }) + : undefined; + if (!bundledFallback) { + defaultRuntime.error(result.error); + process.exit(1); + } + + const existing = cfg.plugins?.load?.paths ?? []; + const mergedPaths = Array.from(new Set([...existing, bundledFallback.localPath])); + let next: OpenClawConfig = { + ...cfg, + plugins: { + ...cfg.plugins, + load: { + ...cfg.plugins?.load, + paths: mergedPaths, + }, + entries: { + ...cfg.plugins?.entries, + [bundledFallback.pluginId]: { + ...(cfg.plugins?.entries?.[bundledFallback.pluginId] as object | undefined), + enabled: true, + }, + }, + }, + }; + next = recordPluginInstall(next, { + pluginId: bundledFallback.pluginId, + source: "path", + spec: raw, + sourcePath: bundledFallback.localPath, + installPath: bundledFallback.localPath, + }); + const slotResult = applySlotSelectionForPlugin(next, bundledFallback.pluginId); + next = slotResult.config; + await writeConfigFile(next); + logSlotWarnings(slotResult.warnings); + defaultRuntime.log( + theme.warn( + `npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`, + ), + ); + defaultRuntime.log(`Installed plugin: ${bundledFallback.pluginId}`); + defaultRuntime.log(`Restart the gateway to load plugins.`); + return; } // Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup. clearPluginManifestRegistryCache(); diff --git a/src/plugins/bundled-sources.test.ts b/src/plugins/bundled-sources.test.ts new file mode 100644 index 00000000000..437b06c193e --- /dev/null +++ b/src/plugins/bundled-sources.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { findBundledPluginByNpmSpec, resolveBundledPluginSources } from "./bundled-sources.js"; + +const discoverOpenClawPluginsMock = vi.fn(); +const loadPluginManifestMock = vi.fn(); + +vi.mock("./discovery.js", () => ({ + discoverOpenClawPlugins: (...args: unknown[]) => discoverOpenClawPluginsMock(...args), +})); + +vi.mock("./manifest.js", () => ({ + loadPluginManifest: (...args: unknown[]) => loadPluginManifestMock(...args), +})); + +describe("bundled plugin sources", () => { + beforeEach(() => { + discoverOpenClawPluginsMock.mockReset(); + loadPluginManifestMock.mockReset(); + }); + + it("resolves bundled sources keyed by plugin id", () => { + discoverOpenClawPluginsMock.mockReturnValue({ + candidates: [ + { + origin: "global", + rootDir: "/global/feishu", + packageName: "@openclaw/feishu", + packageManifest: { install: { npmSpec: "@openclaw/feishu" } }, + }, + { + origin: "bundled", + rootDir: "/app/extensions/feishu", + packageName: "@openclaw/feishu", + packageManifest: { install: { npmSpec: "@openclaw/feishu" } }, + }, + { + origin: "bundled", + rootDir: "/app/extensions/feishu-dup", + packageName: "@openclaw/feishu", + packageManifest: { install: { npmSpec: "@openclaw/feishu" } }, + }, + { + origin: "bundled", + rootDir: "/app/extensions/msteams", + packageName: "@openclaw/msteams", + packageManifest: { install: { npmSpec: "@openclaw/msteams" } }, + }, + ], + diagnostics: [], + }); + + loadPluginManifestMock.mockImplementation((rootDir: string) => { + if (rootDir === "/app/extensions/feishu") { + return { ok: true, manifest: { id: "feishu" } }; + } + if (rootDir === "/app/extensions/msteams") { + return { ok: true, manifest: { id: "msteams" } }; + } + return { + ok: false, + error: "invalid manifest", + manifestPath: `${rootDir}/openclaw.plugin.json`, + }; + }); + + const map = resolveBundledPluginSources({}); + + expect(Array.from(map.keys())).toEqual(["feishu", "msteams"]); + expect(map.get("feishu")).toEqual({ + pluginId: "feishu", + localPath: "/app/extensions/feishu", + npmSpec: "@openclaw/feishu", + }); + }); + + it("finds bundled source by npm spec", () => { + discoverOpenClawPluginsMock.mockReturnValue({ + candidates: [ + { + origin: "bundled", + rootDir: "/app/extensions/feishu", + packageName: "@openclaw/feishu", + packageManifest: { install: { npmSpec: "@openclaw/feishu" } }, + }, + ], + diagnostics: [], + }); + loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "feishu" } }); + + const resolved = findBundledPluginByNpmSpec({ spec: "@openclaw/feishu" }); + const missing = findBundledPluginByNpmSpec({ spec: "@openclaw/not-found" }); + + expect(resolved?.pluginId).toBe("feishu"); + expect(resolved?.localPath).toBe("/app/extensions/feishu"); + expect(missing).toBeUndefined(); + }); +}); diff --git a/src/plugins/bundled-sources.ts b/src/plugins/bundled-sources.ts new file mode 100644 index 00000000000..44ac618f211 --- /dev/null +++ b/src/plugins/bundled-sources.ts @@ -0,0 +1,59 @@ +import { discoverOpenClawPlugins } from "./discovery.js"; +import { loadPluginManifest } from "./manifest.js"; + +export type BundledPluginSource = { + pluginId: string; + localPath: string; + npmSpec?: string; +}; + +export function resolveBundledPluginSources(params: { + workspaceDir?: string; +}): Map { + const discovery = discoverOpenClawPlugins({ workspaceDir: params.workspaceDir }); + const bundled = new Map(); + + for (const candidate of discovery.candidates) { + if (candidate.origin !== "bundled") { + continue; + } + const manifest = loadPluginManifest(candidate.rootDir); + if (!manifest.ok) { + continue; + } + const pluginId = manifest.manifest.id; + if (bundled.has(pluginId)) { + continue; + } + + const npmSpec = + candidate.packageManifest?.install?.npmSpec?.trim() || + candidate.packageName?.trim() || + undefined; + + bundled.set(pluginId, { + pluginId, + localPath: candidate.rootDir, + npmSpec, + }); + } + + return bundled; +} + +export function findBundledPluginByNpmSpec(params: { + spec: string; + workspaceDir?: string; +}): BundledPluginSource | undefined { + const targetSpec = params.spec.trim(); + if (!targetSpec) { + return undefined; + } + const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir }); + for (const source of bundled.values()) { + if (source.npmSpec === targetSpec) { + return source; + } + } + return undefined; +} diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 8bf2a11e3d3..2ba71158065 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -4,10 +4,9 @@ import type { OpenClawConfig } from "../config/config.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import type { UpdateChannel } from "../infra/update-channels.js"; import { resolveUserPath } from "../utils.js"; -import { discoverOpenClawPlugins } from "./discovery.js"; +import { resolveBundledPluginSources } from "./bundled-sources.js"; import { installPluginFromNpmSpec, resolvePluginInstallDir } from "./install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js"; -import { loadPluginManifest } from "./manifest.js"; export type PluginUpdateLogger = { info?: (message: string) => void; @@ -54,12 +53,6 @@ export type PluginChannelSyncResult = { summary: PluginChannelSyncSummary; }; -type BundledPluginSource = { - pluginId: string; - localPath: string; - npmSpec?: string; -}; - type InstallIntegrityDrift = { spec: string; expectedIntegrity: string; @@ -91,40 +84,6 @@ async function readInstalledPackageVersion(dir: string): Promise { - const discovery = discoverOpenClawPlugins({ workspaceDir: params.workspaceDir }); - const bundled = new Map(); - - for (const candidate of discovery.candidates) { - if (candidate.origin !== "bundled") { - continue; - } - const manifest = loadPluginManifest(candidate.rootDir); - if (!manifest.ok) { - continue; - } - const pluginId = manifest.manifest.id; - if (bundled.has(pluginId)) { - continue; - } - - const npmSpec = - candidate.packageManifest?.install?.npmSpec?.trim() || - candidate.packageName?.trim() || - undefined; - - bundled.set(pluginId, { - pluginId, - localPath: candidate.rootDir, - npmSpec, - }); - } - - return bundled; -} - function pathsEqual(left?: string, right?: string): boolean { if (!left || !right) { return false;