diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index 4d0e3e5f59b..a5031a97607 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -18,10 +18,10 @@ vi.mock("../../cli/npm-resolution.js", () => ({ })); vi.mock("../../cli/plugins-command-helpers.js", () => ({ - buildPreferredClawHubSpec: vi.fn(() => null), createPluginInstallLogger: vi.fn(() => ({})), decidePreferredClawHubFallback: vi.fn(() => "fallback_to_npm"), resolveFileNpmSpecToLocalPath: vi.fn(() => null), + resolvePreferredClawHubSpec: vi.fn(() => null), })); vi.mock("../../cli/plugins-install-persist.js", () => ({ diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index b3807073bd6..2549f8a0cf1 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -1,10 +1,10 @@ import fs from "node:fs"; import { buildNpmInstallRecordFields } from "../../cli/npm-resolution.js"; import { - buildPreferredClawHubSpec, createPluginInstallLogger, decidePreferredClawHubFallback, resolveFileNpmSpecToLocalPath, + resolvePreferredClawHubSpec, } from "../../cli/plugins-command-helpers.js"; import { persistPluginInstall } from "../../cli/plugins-install-persist.js"; import type { ConfigSnapshotForInstallPersist } from "../../cli/plugins-install-persist.js"; @@ -256,7 +256,7 @@ async function installPluginFromPluginsCommand(params: { return { ok: true, pluginId: result.pluginId }; } - const preferredClawHubSpec = buildPreferredClawHubSpec(params.raw); + const preferredClawHubSpec = await resolvePreferredClawHubSpec(params.raw); if (preferredClawHubSpec) { const clawhubResult = await installPluginFromClawHub({ spec: preferredClawHubSpec, diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index d572d316966..4da5632b2c3 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -11,6 +11,8 @@ type UnknownMock = Mock<(...args: unknown[]) => unknown>; type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise>; type LoadConfigFn = (typeof import("../config/config.js"))["loadConfig"]; type ParseClawHubPluginSpecFn = (typeof import("../infra/clawhub.js"))["parseClawHubPluginSpec"]; +type FetchClawHubPackageReadinessFn = + (typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"]; type InstallPluginFromMarketplaceFn = (typeof import("../plugins/marketplace.js"))["installPluginFromMarketplace"]; type InstallPluginFromGitSpecFn = @@ -78,6 +80,7 @@ export const installPluginFromNpmSpec: AsyncUnknownMock = vi.fn(); export const installPluginFromPath: AsyncUnknownMock = vi.fn(); export const installPluginFromClawHub: AsyncUnknownMock = vi.fn(); export const parseClawHubPluginSpec: Mock = vi.fn(); +export const fetchClawHubPackageReadiness: Mock = vi.fn(); export const installHooksFromNpmSpec: AsyncUnknownMock = vi.fn(); export const installHooksFromPath: AsyncUnknownMock = vi.fn(); export const recordHookInstall: UnknownMock = vi.fn(); @@ -560,6 +563,16 @@ vi.mock("../plugins/clawhub.js", () => ({ })); vi.mock("../infra/clawhub.js", () => ({ + fetchClawHubPackageReadiness: (( + ...args: Parameters<(typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"]> + ) => + invokeMock< + Parameters<(typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"]>, + ReturnType<(typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"]> + >( + fetchClawHubPackageReadiness, + ...args, + )) as (typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"], parseClawHubPluginSpec: (( ...args: Parameters<(typeof import("../infra/clawhub.js"))["parseClawHubPluginSpec"]> ) => @@ -621,6 +634,7 @@ export function resetPluginsCliTestState() { installPluginFromPath.mockReset(); installPluginFromClawHub.mockReset(); parseClawHubPluginSpec.mockReset(); + fetchClawHubPackageReadiness.mockReset(); installHooksFromNpmSpec.mockReset(); installHooksFromPath.mockReset(); recordHookInstall.mockReset(); diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 0fbd1b182f9..deb01e6f423 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -8,6 +8,7 @@ import { applyExclusiveSlotSelection, buildPluginSnapshotReport, enablePluginInConfig, + fetchClawHubPackageReadiness, installHooksFromNpmSpec, installHooksFromPath, installPluginFromClawHub, @@ -660,6 +661,7 @@ describe("plugins cli install", () => { } as OpenClawConfig; const enabledCfg = createEnabledPluginConfig("demo"); loadConfig.mockReturnValue(cfg); + fetchClawHubPackageReadiness.mockResolvedValue({ readyForOpenClaw: true }); installPluginFromClawHub.mockResolvedValue( createClawHubInstallResult({ pluginId: "demo", @@ -702,6 +704,7 @@ describe("plugins cli install", () => { } as OpenClawConfig; const enabledCfg = createEnabledPluginConfig("demo"); loadConfig.mockReturnValue(cfg); + fetchClawHubPackageReadiness.mockResolvedValue({ phase: "legacy-zip-only" }); installPluginFromClawHub.mockResolvedValue( createClawHubInstallResult({ pluginId: "demo", @@ -735,6 +738,7 @@ describe("plugins cli install", () => { it("falls back to npm when ClawHub does not have the package", async () => { primeNpmPluginFallback(); + fetchClawHubPackageReadiness.mockResolvedValue({ phase: "ready-for-openclaw" }); await runPluginsCommand(["plugins", "install", "demo"]); @@ -750,6 +754,29 @@ describe("plugins cli install", () => { ); }); + it("preserves npm install behavior for bare specs until ClawHub readiness is available", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("demo"); + loadConfig.mockReturnValue(cfg); + fetchClawHubPackageReadiness.mockRejectedValue(new Error("not deployed")); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo")); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "demo"]); + + expect(fetchClawHubPackageReadiness).toHaveBeenCalledWith({ name: "demo" }); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "demo", + }), + ); + }); + it("installs directly from npm when npm: prefix is used", async () => { const cfg = createEmptyPluginConfig(); const enabledCfg = createEnabledPluginConfig("demo"); @@ -1511,6 +1538,7 @@ describe("plugins cli install", () => { }); it("does not fall back to npm when ClawHub rejects a real package", async () => { + fetchClawHubPackageReadiness.mockResolvedValue({ phase: "ready-for-openclaw" }); installPluginFromClawHub.mockResolvedValue({ ok: false, error: 'Use "openclaw skills install demo" instead.', diff --git a/src/cli/plugins-command-helpers.ts b/src/cli/plugins-command-helpers.ts index 2f59aa735bc..844b17e9461 100644 --- a/src/cli/plugins-command-helpers.ts +++ b/src/cli/plugins-command-helpers.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { fetchClawHubPackageReadiness, type ClawHubPackageReadiness } from "../infra/clawhub.js"; import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js"; import type { PluginKind } from "../plugins/plugin-kind.types.js"; @@ -211,6 +212,45 @@ export function buildPreferredClawHubSpec(raw: string): string | null { return `clawhub:${parsed.name}${parsed.selector ? `@${parsed.selector}` : ""}`; } +function normalizeReadinessPhase(readiness: ClawHubPackageReadiness): string { + return normalizeLowercaseStringOrEmpty(String(readiness.phase ?? readiness.status ?? "")); +} + +export function isClawHubReadinessInstallReady( + readiness: ClawHubPackageReadiness | null | undefined, +): boolean { + if (!readiness) { + return false; + } + if ( + readiness.ready === true || + readiness.readyForOpenClaw === true || + readiness.installReady === true + ) { + return true; + } + const phase = normalizeReadinessPhase(readiness); + return ( + phase === "ready-for-openclaw" || phase === "clawpack-ready" || phase === "legacy-zip-only" + ); +} + +export async function resolvePreferredClawHubSpec(raw: string): Promise { + const parsed = parseRegistryNpmSpec(raw); + if (!parsed) { + return null; + } + try { + const readiness = await fetchClawHubPackageReadiness({ name: parsed.name }); + if (!isClawHubReadinessInstallReady(readiness)) { + return null; + } + } catch { + return null; + } + return `clawhub:${parsed.name}${parsed.selector ? `@${parsed.selector}` : ""}`; +} + export function parseNpmPrefixSpec(raw: string): string | null { const trimmed = raw.trim(); if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("npm:")) { diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index bd5877db45f..0b4b71e92ab 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -38,12 +38,12 @@ import { resolveBundledInstallPlanForNpmFailure, } from "./plugin-install-plan.js"; import { - buildPreferredClawHubSpec, createHookPackInstallLogger, createPluginInstallLogger, decidePreferredClawHubFallback, formatPluginInstallWithHookFallbackError, parseNpmPrefixSpec, + resolvePreferredClawHubSpec, } from "./plugins-command-helpers.js"; import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js"; import type { ConfigSnapshotForInstallPersist } from "./plugins-install-persist.js"; @@ -776,7 +776,7 @@ export async function runPluginInstallCommand(params: { return; } - const preferredClawHubSpec = buildPreferredClawHubSpec(raw); + const preferredClawHubSpec = await resolvePreferredClawHubSpec(raw); if (preferredClawHubSpec) { const clawhubResult = await installPluginFromClawHub({ ...safetyOverrides, diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index 0b2a02b5edf..4da5b677d37 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -7,6 +7,8 @@ import { withTempDir } from "../test-helpers/temp-dir.js"; import { downloadClawHubPackageArchive, downloadClawHubSkillArchive, + fetchClawHubPackageArtifact, + fetchClawHubPackageReadiness, normalizeClawHubSha256Integrity, normalizeClawHubSha256Hex, parseClawHubPluginSpec, @@ -222,6 +224,74 @@ describe("clawhub helpers", () => { await expect(searchClawHubSkills({ query: "calendar", fetchImpl })).resolves.toEqual([]); }); + + it("fetches typed package readiness reports", async () => { + let requestedUrl = ""; + await expect( + fetchClawHubPackageReadiness({ + name: "@openclaw/diagnostics-otel", + fetchImpl: async (input) => { + requestedUrl = input instanceof Request ? input.url : String(input); + return new Response( + JSON.stringify({ + package: { name: "@openclaw/diagnostics-otel", isOfficial: true }, + phase: "legacy-zip-only", + blockers: [], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }, + }), + ).resolves.toEqual({ + package: { name: "@openclaw/diagnostics-otel", isOfficial: true }, + phase: "legacy-zip-only", + blockers: [], + }); + expect(new URL(requestedUrl).pathname).toBe( + "/api/v1/packages/%40openclaw%2Fdiagnostics-otel/readiness", + ); + }); + + it("fetches typed package artifact resolver reports", async () => { + let requestedUrl = ""; + await expect( + fetchClawHubPackageArtifact({ + name: "@openclaw/diagnostics-otel", + version: "2026.3.22", + fetchImpl: async (input) => { + requestedUrl = input instanceof Request ? input.url : String(input); + return new Response( + JSON.stringify({ + artifact: { + source: "clawhub", + artifactKind: "npm-pack", + packageName: "@openclaw/diagnostics-otel", + version: "2026.3.22", + downloadUrl: "https://clawhub.ai/api/v1/clawpacks/abc", + npmIntegrity: "sha512-demo", + npmShasum: "abc", + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }, + }), + ).resolves.toEqual({ + artifact: { + source: "clawhub", + artifactKind: "npm-pack", + packageName: "@openclaw/diagnostics-otel", + version: "2026.3.22", + downloadUrl: "https://clawhub.ai/api/v1/clawpacks/abc", + npmIntegrity: "sha512-demo", + npmShasum: "abc", + }, + }); + expect(new URL(requestedUrl).pathname).toBe( + "/api/v1/packages/%40openclaw%2Fdiagnostics-otel/versions/2026.3.22/artifact", + ); + }); + it("downloads package archives to sanitized temp paths and cleans them up", async () => { const archive = await downloadClawHubPackageArchive({ name: "@hyf/zai-external-alpha", diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index e93bb676837..8f226f99ba1 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -53,6 +53,43 @@ export type ClawHubPackageArtifactSummary = { tarballUrl?: string | null; legacyDownloadUrl?: string | null; }; +export type ClawHubArtifactKind = "legacy-zip" | "npm-pack"; +export type ClawHubArtifactScanState = + | "pending" + | "clean" + | "suspicious" + | "malicious" + | "not-run" + | string; +export type ClawHubArtifactModerationState = "approved" | "quarantined" | "revoked" | string; +export type ClawHubResolvedArtifact = + | { + source: "clawhub"; + artifactKind: "legacy-zip"; + packageName: string; + version: string; + downloadUrl?: string | null; + artifactSha256?: string | null; + scanState?: ClawHubArtifactScanState | null; + moderationState?: ClawHubArtifactModerationState | null; + } + | { + source: "clawhub"; + artifactKind: "npm-pack"; + packageName: string; + version: string; + downloadUrl?: string | null; + npmIntegrity: string; + npmShasum?: string | null; + artifactSha256?: string | null; + scanState?: ClawHubArtifactScanState | null; + moderationState?: ClawHubArtifactModerationState | null; + }; +export type ClawHubPackageArtifactResolverResponse = { + package?: { name?: string | null } | null; + version?: { version?: string | null } | string | null; + artifact?: ClawHubResolvedArtifact | null; +}; export type ClawHubPackageClawPackSummary = { available: boolean; specVersion?: number | null; @@ -70,6 +107,33 @@ export type ClawHubPackageClawPackSummary = { environment?: ClawHubPackageEnvironmentSummary | null; runtimeBundles?: unknown[]; }; +export type ClawHubPackageReadinessPhase = + | "planned" + | "published" + | "clawpack-ready" + | "legacy-zip-only" + | "metadata-ready" + | "blocked" + | "ready-for-openclaw" + | string; +export type ClawHubPackageReadiness = { + ready?: boolean | null; + readyForOpenClaw?: boolean | null; + installReady?: boolean | null; + phase?: ClawHubPackageReadinessPhase | null; + status?: ClawHubPackageReadinessPhase | null; + package?: { + name?: string | null; + family?: ClawHubPackageFamily | string | null; + channel?: ClawHubPackageChannel | string | null; + isOfficial?: boolean | null; + } | null; + packageName?: string | null; + artifactKind?: ClawHubArtifactKind | string | null; + blockers?: string[]; + scanState?: ClawHubArtifactScanState | null; + moderationState?: ClawHubArtifactModerationState | null; +}; export type ClawHubPackageListItem = { name: string; displayName: string; @@ -591,6 +655,41 @@ export async function fetchClawHubPackageVersion(params: { }); } +export async function fetchClawHubPackageArtifact(params: { + name: string; + version: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; + fetchImpl?: FetchLike; +}): Promise { + return await fetchJson({ + baseUrl: params.baseUrl, + path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent( + params.version, + )}/artifact`, + token: params.token, + timeoutMs: params.timeoutMs, + fetchImpl: params.fetchImpl, + }); +} + +export async function fetchClawHubPackageReadiness(params: { + name: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; + fetchImpl?: FetchLike; +}): Promise { + return await fetchJson({ + baseUrl: params.baseUrl, + path: `/api/v1/packages/${encodeURIComponent(params.name)}/readiness`, + token: params.token, + timeoutMs: params.timeoutMs, + fetchImpl: params.fetchImpl, + }); +} + export async function searchClawHubPackages(params: { query: string; family?: ClawHubPackageFamily;