mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
Add marketplace feed entries command (#96158)
Merged via squash.
Prepared head SHA: 1289a25156
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
This commit is contained in:
+13
-1
@@ -52,6 +52,9 @@ openclaw plugins uninstall <id>
|
||||
openclaw plugins doctor
|
||||
openclaw plugins update <id-or-npm-spec>
|
||||
openclaw plugins update --all
|
||||
openclaw plugins marketplace entries
|
||||
openclaw plugins marketplace entries --offline
|
||||
openclaw plugins marketplace entries --json
|
||||
openclaw plugins marketplace list <marketplace>
|
||||
openclaw plugins marketplace list <marketplace> --json
|
||||
openclaw plugins marketplace refresh
|
||||
@@ -524,6 +527,11 @@ Use `plugins registry` to inspect whether the persisted registry is present, cur
|
||||
### Marketplace
|
||||
|
||||
```bash
|
||||
openclaw plugins marketplace entries
|
||||
openclaw plugins marketplace entries --offline
|
||||
openclaw plugins marketplace entries --json
|
||||
openclaw plugins marketplace entries --feed-profile <name>
|
||||
openclaw plugins marketplace entries --feed-url <url>
|
||||
openclaw plugins marketplace list <source>
|
||||
openclaw plugins marketplace list <source> --json
|
||||
openclaw plugins marketplace refresh
|
||||
@@ -532,7 +540,11 @@ openclaw plugins marketplace refresh --feed-url <url>
|
||||
openclaw plugins marketplace refresh --expected-sha256 <sha256> --json
|
||||
```
|
||||
|
||||
Marketplace list accepts a local marketplace path, a `marketplace.json` path, a GitHub shorthand like `owner/repo`, a GitHub repo URL, or a git URL. `--json` prints the resolved source label plus the parsed marketplace manifest and plugin entries.
|
||||
`plugins marketplace entries` lists entries from the configured OpenClaw marketplace feed. By default it attempts the hosted feed and falls back to the latest accepted snapshot or bundled data. Use `--feed-profile <name>` to read a specific configured profile, `--feed-url <url>` to read an explicit hosted feed URL, and `--offline` to read the latest accepted snapshot without fetching the feed.
|
||||
|
||||
`plugins marketplace refresh` refreshes the configured hosted feed snapshot and reports whether OpenClaw accepted hosted data, a hosted snapshot, or bundled fallback data. Use `--expected-sha256` when a caller needs the command to fail unless a fresh hosted payload matches a pinned checksum.
|
||||
|
||||
Marketplace `list` accepts a local marketplace path, a `marketplace.json` path, a GitHub shorthand like `owner/repo`, a GitHub repo URL, or a git URL. `--json` prints the resolved source label plus the parsed marketplace manifest and plugin entries.
|
||||
|
||||
Marketplace refresh loads a hosted OpenClaw marketplace feed and persists the
|
||||
validated response as the local hosted-feed snapshot. Without options, it uses
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("plugins cli lazy runtime boundary", () => {
|
||||
vi.doMock("./plugins-cli.runtime.js", () => {
|
||||
runtimeLoaded();
|
||||
return {
|
||||
runPluginMarketplaceEntriesCommand: vi.fn(),
|
||||
runPluginMarketplaceListCommand: vi.fn(),
|
||||
runPluginMarketplaceRefreshCommand: vi.fn(),
|
||||
runPluginsDisableCommand: vi.fn(),
|
||||
@@ -50,6 +51,7 @@ describe("plugins cli lazy runtime boundary", () => {
|
||||
vi.doMock("./plugins-cli.runtime.js", () => {
|
||||
runtimeLoaded();
|
||||
return {
|
||||
runPluginMarketplaceEntriesCommand: vi.fn(),
|
||||
runPluginMarketplaceListCommand: vi.fn(),
|
||||
runPluginMarketplaceRefreshCommand: vi.fn(),
|
||||
runPluginsDisableCommand: vi.fn(),
|
||||
@@ -70,9 +72,37 @@ describe("plugins cli lazy runtime boundary", () => {
|
||||
expect(runPluginsRegistryCommand).toHaveBeenCalledWith(expect.objectContaining({ json: true }));
|
||||
});
|
||||
|
||||
it("loads the plugins runtime for marketplace entries", async () => {
|
||||
const runPluginMarketplaceEntriesCommand = vi.fn().mockResolvedValue(undefined);
|
||||
vi.doMock("./plugins-cli.runtime.js", () => ({
|
||||
runPluginMarketplaceEntriesCommand,
|
||||
runPluginMarketplaceListCommand: vi.fn(),
|
||||
runPluginMarketplaceRefreshCommand: vi.fn(),
|
||||
runPluginsDisableCommand: vi.fn(),
|
||||
runPluginsDoctorCommand: vi.fn(),
|
||||
runPluginsEnableCommand: vi.fn(),
|
||||
runPluginsInstallAction: vi.fn(),
|
||||
runPluginsRegistryCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
const { registerPluginsCli } = await import("./plugins-cli.js");
|
||||
const program = new Command();
|
||||
registerPluginsCli(program);
|
||||
|
||||
await program.parseAsync(
|
||||
["plugins", "marketplace", "entries", "--feed-profile", "acme", "--offline", "--json"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(runPluginMarketplaceEntriesCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ feedProfile: "acme", offline: true, json: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads the plugins runtime for marketplace refresh", async () => {
|
||||
const runPluginMarketplaceRefreshCommand = vi.fn().mockResolvedValue(undefined);
|
||||
vi.doMock("./plugins-cli.runtime.js", () => ({
|
||||
runPluginMarketplaceEntriesCommand: vi.fn(),
|
||||
runPluginMarketplaceListCommand: vi.fn(),
|
||||
runPluginMarketplaceRefreshCommand,
|
||||
runPluginsDisableCommand: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// Covers the hosted OpenClaw marketplace feed entries command.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const defaultRuntime = {
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
log: vi.fn(),
|
||||
writeJson: vi.fn(),
|
||||
};
|
||||
return {
|
||||
defaultRuntime,
|
||||
getRuntimeConfig: vi.fn(),
|
||||
loadConfiguredHostedOfficialExternalPluginCatalogEntries: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
assertConfigWriteAllowedInCurrentMode: vi.fn(),
|
||||
getRuntimeConfig: mocks.getRuntimeConfig,
|
||||
readConfigFileSnapshot: vi.fn(),
|
||||
replaceConfigFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: mocks.defaultRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/official-external-plugin-catalog.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../plugins/official-external-plugin-catalog.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfiguredHostedOfficialExternalPluginCatalogEntries:
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries,
|
||||
};
|
||||
});
|
||||
|
||||
describe("plugins marketplace entries", () => {
|
||||
beforeEach(() => {
|
||||
mocks.defaultRuntime.error.mockClear();
|
||||
mocks.defaultRuntime.exit.mockClear();
|
||||
mocks.defaultRuntime.log.mockClear();
|
||||
mocks.defaultRuntime.writeJson.mockClear();
|
||||
mocks.getRuntimeConfig.mockReset();
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockReset();
|
||||
});
|
||||
|
||||
it("lists entries from the configured marketplace feed as JSON", async () => {
|
||||
const config = {
|
||||
marketplaces: {
|
||||
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
|
||||
sources: { "acme-npm": { type: "npm" as const } },
|
||||
},
|
||||
};
|
||||
mocks.getRuntimeConfig.mockReturnValue(config);
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
|
||||
source: "hosted-snapshot",
|
||||
entries: [
|
||||
{
|
||||
name: "@acme/calendar",
|
||||
version: "1.2.3",
|
||||
kind: "plugin",
|
||||
state: "available",
|
||||
publisher: { trust: "official" },
|
||||
install: {
|
||||
candidates: [{ sourceRef: "acme-npm", package: "@acme/calendar", version: "1.2.3" }],
|
||||
},
|
||||
openclaw: {
|
||||
plugin: { id: "acme-calendar", label: "Acme Calendar" },
|
||||
},
|
||||
},
|
||||
],
|
||||
feed: {
|
||||
schemaVersion: 1,
|
||||
id: "acme-marketplace",
|
||||
generatedAt: "2026-06-23T00:00:00.000Z",
|
||||
sequence: 7,
|
||||
entries: [],
|
||||
},
|
||||
metadata: {
|
||||
url: "https://packages.acme.example/openclaw/feed",
|
||||
status: 200,
|
||||
checksum: "feed-sha",
|
||||
},
|
||||
snapshot: {
|
||||
body: "{}",
|
||||
metadata: {
|
||||
url: "https://packages.acme.example/openclaw/feed",
|
||||
status: 200,
|
||||
checksum: "feed-sha",
|
||||
},
|
||||
savedAt: "2026-06-23T01:02:03.000Z",
|
||||
},
|
||||
error: "hosted catalog feed offline mode",
|
||||
});
|
||||
|
||||
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
|
||||
await runPluginMarketplaceEntriesCommand({ feedProfile: "acme", offline: true, json: true });
|
||||
|
||||
expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith(
|
||||
config,
|
||||
{ feedProfile: "acme", offline: true },
|
||||
);
|
||||
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "hosted-snapshot",
|
||||
entryCount: 1,
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
id: "acme-calendar",
|
||||
label: "Acme Calendar",
|
||||
name: "@acme/calendar",
|
||||
version: "1.2.3",
|
||||
install: expect.objectContaining({ npmSpec: "@acme/calendar@1.2.3" }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts query-bearing feed URLs from entries output", async () => {
|
||||
mocks.getRuntimeConfig.mockReturnValue({});
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
|
||||
source: "bundled-fallback",
|
||||
entries: [],
|
||||
error:
|
||||
"hosted catalog feed fetch failed for https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
|
||||
metadata: {
|
||||
url: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
|
||||
status: 503,
|
||||
},
|
||||
});
|
||||
|
||||
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
|
||||
await runPluginMarketplaceEntriesCommand({
|
||||
feedUrl: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ url: "https://clawhub.ai/v1/feeds/plugins" }),
|
||||
error: "hosted catalog feed fetch failed for https://clawhub.ai/v1/feeds/plugins",
|
||||
}),
|
||||
);
|
||||
|
||||
mocks.defaultRuntime.writeJson.mockClear();
|
||||
mocks.defaultRuntime.log.mockClear();
|
||||
|
||||
await runPluginMarketplaceEntriesCommand({
|
||||
feedUrl: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
|
||||
});
|
||||
|
||||
const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n");
|
||||
expect(output).toContain("https://clawhub.ai/v1/feeds/plugins");
|
||||
expect(output).not.toContain("token=secret");
|
||||
expect(output).not.toContain("#frag");
|
||||
});
|
||||
|
||||
it("prints bundled fallback entries without failing", async () => {
|
||||
mocks.getRuntimeConfig.mockReturnValue({});
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
|
||||
source: "bundled-fallback",
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/acpx",
|
||||
openclaw: {
|
||||
plugin: { id: "acpx", label: "ACP" },
|
||||
install: {
|
||||
clawhubSpec: "clawhub:@openclaw/acpx",
|
||||
npmSpec: "@openclaw/acpx",
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
error: "hosted catalog feed offline mode",
|
||||
});
|
||||
|
||||
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
|
||||
await runPluginMarketplaceEntriesCommand({ offline: true });
|
||||
|
||||
const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n");
|
||||
expect(output).toContain("bundled fallback");
|
||||
expect(output).toContain("acpx");
|
||||
expect(output).toContain("@openclaw/acpx");
|
||||
expect(output).not.toContain("clawhub:@openclaw/acpx");
|
||||
expect(output).toContain("hosted catalog feed offline mode");
|
||||
expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { defaultRuntime } from "../runtime.js";
|
||||
import { shortenHomeInString } from "../utils.js";
|
||||
import { formatMissingPluginMessage } from "./error-format.js";
|
||||
import type {
|
||||
PluginMarketplaceEntriesOptions,
|
||||
PluginMarketplaceListOptions,
|
||||
PluginMarketplaceRefreshOptions,
|
||||
PluginRegistryOptions,
|
||||
@@ -468,6 +469,22 @@ type MarketplaceRefreshPayload = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type MarketplaceEntryPayload = {
|
||||
id?: string;
|
||||
label: string;
|
||||
kind?: string;
|
||||
name?: string;
|
||||
version?: string;
|
||||
install?: {
|
||||
defaultChoice?: string;
|
||||
clawhubSpec?: string;
|
||||
npmSpec?: string;
|
||||
localPath?: string;
|
||||
expectedIntegrity?: string;
|
||||
minHostVersion?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function buildMarketplaceRefreshPayload(
|
||||
result: Awaited<
|
||||
ReturnType<
|
||||
@@ -545,6 +562,21 @@ function sanitizeMarketplaceRefreshPayload(
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function formatMarketplaceEntryInstall(entry: MarketplaceEntryPayload): string | undefined {
|
||||
if (entry.install?.defaultChoice === "npm") {
|
||||
return entry.install.npmSpec ?? entry.install.clawhubSpec ?? entry.install.localPath;
|
||||
}
|
||||
return entry.install?.clawhubSpec ?? entry.install?.npmSpec ?? entry.install?.localPath;
|
||||
}
|
||||
|
||||
function formatMarketplaceEntryLine(entry: MarketplaceEntryPayload): string {
|
||||
const id = entry.id ?? entry.name ?? entry.label;
|
||||
const install = formatMarketplaceEntryInstall(entry);
|
||||
const suffix = install ? " " + theme.muted(install) : "";
|
||||
const label = entry.label !== id ? " " + theme.muted(entry.label) : "";
|
||||
return theme.command(id) + label + suffix;
|
||||
}
|
||||
|
||||
function formatMarketplaceRefreshSource(source: MarketplaceRefreshPayload["source"]): string {
|
||||
if (source === "hosted") {
|
||||
return theme.success("hosted");
|
||||
@@ -581,6 +613,80 @@ function formatPinnedMarketplaceRefreshFailure(payload: MarketplaceRefreshPayloa
|
||||
return `Pinned marketplace feed refresh did not accept a fresh hosted payload (source: ${payload.source}).`;
|
||||
}
|
||||
|
||||
/** List entries from the configured OpenClaw marketplace feed. */
|
||||
export async function runPluginMarketplaceEntriesCommand(
|
||||
opts: PluginMarketplaceEntriesOptions,
|
||||
): Promise<void> {
|
||||
const catalog = await import("../plugins/official-external-plugin-catalog.js");
|
||||
const cfg = getRuntimeConfig();
|
||||
const result = await catalog.loadConfiguredHostedOfficialExternalPluginCatalogEntries(cfg, {
|
||||
...(opts.feedProfile ? { feedProfile: opts.feedProfile } : {}),
|
||||
...(opts.feedUrl ? { feedUrl: opts.feedUrl } : {}),
|
||||
...(opts.offline ? { offline: true } : {}),
|
||||
});
|
||||
const summary = sanitizeMarketplaceRefreshPayload(buildMarketplaceRefreshPayload(result), {
|
||||
feedUrl: opts.feedUrl,
|
||||
});
|
||||
const entries: MarketplaceEntryPayload[] = result.entries.map((entry) => {
|
||||
const id = catalog.resolveOfficialExternalPluginId(entry);
|
||||
const install =
|
||||
catalog.resolveOfficialExternalPluginInstall(entry, { catalogConfig: cfg.marketplaces }) ??
|
||||
undefined;
|
||||
const payload: MarketplaceEntryPayload = {
|
||||
label: catalog.resolveOfficialExternalPluginLabel(entry),
|
||||
};
|
||||
if (id) {
|
||||
payload.id = id;
|
||||
}
|
||||
if (entry.kind) {
|
||||
payload.kind = entry.kind;
|
||||
}
|
||||
if (entry.name) {
|
||||
payload.name = entry.name;
|
||||
}
|
||||
if (entry.version) {
|
||||
payload.version = entry.version;
|
||||
}
|
||||
if (install) {
|
||||
payload.install = install;
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.writeJson({ ...summary, entries, entryCount: entries.length });
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
theme.muted("Source:") + " " + formatMarketplaceRefreshSource(summary.source),
|
||||
theme.muted("Entries:") + " " + String(entries.length),
|
||||
];
|
||||
if (summary.feed) {
|
||||
lines.push(
|
||||
theme.muted("Feed:") +
|
||||
" " +
|
||||
summary.feed.id +
|
||||
" " +
|
||||
theme.muted("sequence " + String(summary.feed.sequence)),
|
||||
);
|
||||
}
|
||||
if (summary.metadata?.url) {
|
||||
lines.push(theme.muted("URL:") + " " + summary.metadata.url);
|
||||
}
|
||||
if (summary.snapshot?.savedAt) {
|
||||
lines.push(theme.muted("Snapshot:") + " " + summary.snapshot.savedAt);
|
||||
}
|
||||
if (summary.error) {
|
||||
lines.push(theme.muted("Fallback reason:") + " " + summary.error);
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(...entries.map(formatMarketplaceEntryLine));
|
||||
}
|
||||
defaultRuntime.log(lines.join("\n"));
|
||||
}
|
||||
|
||||
/** Refresh the configured OpenClaw marketplace feed snapshot. */
|
||||
export async function runPluginMarketplaceRefreshCommand(
|
||||
opts: PluginMarketplaceRefreshOptions,
|
||||
|
||||
@@ -26,6 +26,13 @@ export type PluginMarketplaceListOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type PluginMarketplaceEntriesOptions = {
|
||||
feedProfile?: string;
|
||||
feedUrl?: string;
|
||||
json?: boolean;
|
||||
offline?: boolean;
|
||||
};
|
||||
|
||||
export type PluginMarketplaceRefreshOptions = {
|
||||
expectedSha256?: string;
|
||||
feedProfile?: string;
|
||||
@@ -283,6 +290,18 @@ export function registerPluginsCli(program: Command) {
|
||||
.command("marketplace")
|
||||
.description("Inspect Claude-compatible plugin marketplaces");
|
||||
|
||||
marketplace
|
||||
.command("entries")
|
||||
.description("List entries from the configured OpenClaw marketplace feed")
|
||||
.option("--feed-profile <name>", "Configured marketplace feed profile to list")
|
||||
.option("--feed-url <url>", "Explicit hosted marketplace feed URL")
|
||||
.option("--offline", "Read the latest accepted snapshot without fetching the feed", false)
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (opts: PluginMarketplaceEntriesOptions) => {
|
||||
const { runPluginMarketplaceEntriesCommand } = await loadPluginsRuntime();
|
||||
await runPluginMarketplaceEntriesCommand(opts);
|
||||
});
|
||||
|
||||
marketplace
|
||||
.command("refresh")
|
||||
.description("Refresh the configured OpenClaw marketplace feed snapshot")
|
||||
|
||||
Reference in New Issue
Block a user