diff --git a/CHANGELOG.md b/CHANGELOG.md index a8f88b4c8ce..8d5f5485201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Gateway/startup: skip plugin-backed auth-profile overlays during startup secrets preflight, reducing gateway readiness latency while keeping reload and OAuth recovery paths overlay-capable. (#68327) Thanks @JIRBOY. +- Plugins/onboarding: carry ClawHub install metadata through channel setup catalogs so missing channel plugins can install from ClawHub before npm/local fallback. Thanks @vincentkoc. ### Fixes diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index ce5310ff145..ad6c3022537 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -32,14 +32,15 @@ export type ChannelUiCatalog = { byId: Record; }; +export type ChannelPluginCatalogInstall = PluginPackageInstall & + ({ clawhubSpec: string } | { npmSpec: string }); + export type ChannelPluginCatalogEntry = { id: string; pluginId?: string; origin?: PluginOrigin; meta: ChannelMeta; - install: PluginPackageInstall & { - npmSpec: string; - }; + install: ChannelPluginCatalogInstall; installSource?: PluginInstallSourceInfo; }; @@ -210,19 +211,34 @@ function resolveInstallInfo(params: { packageDir?: string; workspaceDir?: string; }): ChannelPluginCatalogEntry["install"] | null { - const npmSpec = params.install?.npmSpec?.trim() ?? params.packageName?.trim(); - if (!npmSpec) { + const clawhubSpec = normalizeOptionalString(params.install?.clawhubSpec); + const npmSpec = + normalizeOptionalString(params.install?.npmSpec) ?? normalizeOptionalString(params.packageName); + if (!clawhubSpec && !npmSpec) { return null; } let localPath = normalizeOptionalString(params.install?.localPath); if (!localPath && params.workspaceDir && params.packageDir) { localPath = path.relative(params.workspaceDir, params.packageDir) || undefined; } - const defaultChoice = params.install?.defaultChoice ?? (localPath ? "local" : "npm"); + const requestedDefaultChoice = params.install?.defaultChoice; + const defaultChoice = + requestedDefaultChoice === "clawhub" && clawhubSpec + ? "clawhub" + : requestedDefaultChoice === "npm" && npmSpec + ? "npm" + : requestedDefaultChoice === "local" && localPath + ? "local" + : clawhubSpec + ? "clawhub" + : localPath + ? "local" + : "npm"; return { - npmSpec, + ...(clawhubSpec ? { clawhubSpec } : {}), + ...(npmSpec ? { npmSpec } : {}), ...(localPath ? { localPath } : {}), - ...(defaultChoice ? { defaultChoice } : {}), + defaultChoice, ...(params.install?.minHostVersion ? { minHostVersion: params.install.minHostVersion } : {}), ...(params.install?.expectedIntegrity ? { expectedIntegrity: params.install.expectedIntegrity } diff --git a/src/channels/plugins/contracts/test-helpers/channel-plugin-catalog-contract-suites.ts b/src/channels/plugins/contracts/test-helpers/channel-plugin-catalog-contract-suites.ts index fe55d518566..c60344f6fd3 100644 --- a/src/channels/plugins/contracts/test-helpers/channel-plugin-catalog-contract-suites.ts +++ b/src/channels/plugins/contracts/test-helpers/channel-plugin-catalog-contract-suites.ts @@ -287,6 +287,78 @@ export function describeChannelPluginCatalogEntriesContract() { }; }, }, + { + name: "accepts external manifest entries with ClawHub-only install metadata", + setup: () => { + const dir = fs.mkdtempSync( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-catalog-clawhub-"), + ); + const catalogPath = path.join(dir, "catalog.json"); + fs.writeFileSync( + catalogPath, + JSON.stringify({ + $schema: "./manifest.schema.json", + schemaVersion: 1, + description: + "Extension manifest. Declares plugin packages that OpenClaw can discover during onboarding and install on demand via `openclaw plugins install`.", + entries: [ + { + source: "external", + kind: "channel", + openclaw: { + channel: { + id: "clawhub-chat", + label: "ClawHub Chat", + selectionLabel: "ClawHub Chat", + detailLabel: "ClawHub", + docsPath: "/channels/clawhub-chat", + docsLabel: "clawhub chat", + blurb: "ClawHub-backed chat channel.", + aliases: ["chchat"], + order: 47, + }, + install: { + clawhubSpec: "clawhub:openclaw/clawhub-chat@2026.5.2", + defaultChoice: "clawhub", + minHostVersion: ">=2026.5.1", + }, + }, + }, + ], + }), + ); + return { + channelId: "clawhub-chat", + catalogPaths: [catalogPath], + expected: { + id: "clawhub-chat", + meta: { + label: "ClawHub Chat", + selectionLabel: "ClawHub Chat", + detailLabel: "ClawHub", + docsPath: "/channels/clawhub-chat", + docsLabel: "clawhub chat", + blurb: "ClawHub-backed chat channel.", + }, + install: { + clawhubSpec: "clawhub:openclaw/clawhub-chat@2026.5.2", + defaultChoice: "clawhub", + minHostVersion: ">=2026.5.1", + }, + installSource: { + defaultChoice: "clawhub", + clawhub: { + spec: "clawhub:openclaw/clawhub-chat@2026.5.2", + packageName: "openclaw/clawhub-chat", + version: "2026.5.2", + exactVersion: true, + }, + warnings: [], + }, + }, + }; + }, + }, { name: "accepts rich external manifest entries for yuanbao with pinned npm metadata", setup: () => { diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index ab7bab3504f..66cecc9c7c6 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -528,6 +528,50 @@ describe("ensureChannelSetupPluginInstalled", () => { ); }); + it("offers ClawHub as the first-class install source for channel catalog entries", async () => { + const runtime = makeRuntime(); + const { prompter, select } = makeSkipInstallPrompter(); + const cfg: OpenClawConfig = { update: { channel: "beta" } }; + vi.mocked(fs.existsSync).mockReturnValue(false); + resolveBundledPluginSources.mockReturnValue(new Map()); + + await ensureChannelSetupPluginInstalled({ + cfg, + entry: { + id: "clawhub-chat", + pluginId: "clawhub-chat", + meta: { + id: "clawhub-chat", + label: "ClawHub Chat", + selectionLabel: "ClawHub Chat", + docsPath: "/channels/clawhub-chat", + blurb: "Test", + }, + install: { + clawhubSpec: "clawhub:openclaw/clawhub-chat@2026.5.2", + defaultChoice: "clawhub", + }, + }, + prompter, + runtime, + }); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: "clawhub", + options: [ + expect.objectContaining({ + value: "clawhub", + label: "Download from ClawHub (clawhub:openclaw/clawhub-chat@2026.5.2)", + }), + expect.objectContaining({ + value: "skip", + }), + ], + }), + ); + }); + it("falls back to local path after npm install failure", async () => { const runtime = makeRuntime(); const note = vi.fn(async () => {});