Skip to content

Commit 9bb0043

Browse files
authored
Add marketplace feed entries command (#96158)
Merged via squash. Prepared head SHA: 1289a25 Co-authored-by: giodl73-repo <[email protected]> Co-authored-by: giodl73-repo <[email protected]> Reviewed-by: @giodl73-repo
1 parent d1d0176 commit 9bb0043

5 files changed

Lines changed: 362 additions & 1 deletion

File tree

docs/cli/plugins.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ openclaw plugins uninstall <id>
5252
openclaw plugins doctor
5353
openclaw plugins update <id-or-npm-spec>
5454
openclaw plugins update --all
55+
openclaw plugins marketplace entries
56+
openclaw plugins marketplace entries --offline
57+
openclaw plugins marketplace entries --json
5558
openclaw plugins marketplace list <marketplace>
5659
openclaw plugins marketplace list <marketplace> --json
5760
openclaw plugins marketplace refresh
@@ -524,6 +527,11 @@ Use `plugins registry` to inspect whether the persisted registry is present, cur
524527
### Marketplace
525528

526529
```bash
530+
openclaw plugins marketplace entries
531+
openclaw plugins marketplace entries --offline
532+
openclaw plugins marketplace entries --json
533+
openclaw plugins marketplace entries --feed-profile <name>
534+
openclaw plugins marketplace entries --feed-url <url>
527535
openclaw plugins marketplace list <source>
528536
openclaw plugins marketplace list <source> --json
529537
openclaw plugins marketplace refresh
@@ -532,7 +540,11 @@ openclaw plugins marketplace refresh --feed-url <url>
532540
openclaw plugins marketplace refresh --expected-sha256 <sha256> --json
533541
```
534542

535-
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.
543+
`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.
544+
545+
`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.
546+
547+
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.
536548

537549
Marketplace refresh loads a hosted OpenClaw marketplace feed and persists the
538550
validated response as the local hosted-feed snapshot. Without options, it uses

src/cli/plugins-cli.lazy.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ describe("plugins cli lazy runtime boundary", () => {
1717
vi.doMock("./plugins-cli.runtime.js", () => {
1818
runtimeLoaded();
1919
return {
20+
runPluginMarketplaceEntriesCommand: vi.fn(),
2021
runPluginMarketplaceListCommand: vi.fn(),
2122
runPluginMarketplaceRefreshCommand: vi.fn(),
2223
runPluginsDisableCommand: vi.fn(),
@@ -50,6 +51,7 @@ describe("plugins cli lazy runtime boundary", () => {
5051
vi.doMock("./plugins-cli.runtime.js", () => {
5152
runtimeLoaded();
5253
return {
54+
runPluginMarketplaceEntriesCommand: vi.fn(),
5355
runPluginMarketplaceListCommand: vi.fn(),
5456
runPluginMarketplaceRefreshCommand: vi.fn(),
5557
runPluginsDisableCommand: vi.fn(),
@@ -70,9 +72,37 @@ describe("plugins cli lazy runtime boundary", () => {
7072
expect(runPluginsRegistryCommand).toHaveBeenCalledWith(expect.objectContaining({ json: true }));
7173
});
7274

75+
it("loads the plugins runtime for marketplace entries", async () => {
76+
const runPluginMarketplaceEntriesCommand = vi.fn().mockResolvedValue(undefined);
77+
vi.doMock("./plugins-cli.runtime.js", () => ({
78+
runPluginMarketplaceEntriesCommand,
79+
runPluginMarketplaceListCommand: vi.fn(),
80+
runPluginMarketplaceRefreshCommand: vi.fn(),
81+
runPluginsDisableCommand: vi.fn(),
82+
runPluginsDoctorCommand: vi.fn(),
83+
runPluginsEnableCommand: vi.fn(),
84+
runPluginsInstallAction: vi.fn(),
85+
runPluginsRegistryCommand: vi.fn(),
86+
}));
87+
88+
const { registerPluginsCli } = await import("./plugins-cli.js");
89+
const program = new Command();
90+
registerPluginsCli(program);
91+
92+
await program.parseAsync(
93+
["plugins", "marketplace", "entries", "--feed-profile", "acme", "--offline", "--json"],
94+
{ from: "user" },
95+
);
96+
97+
expect(runPluginMarketplaceEntriesCommand).toHaveBeenCalledWith(
98+
expect.objectContaining({ feedProfile: "acme", offline: true, json: true }),
99+
);
100+
});
101+
73102
it("loads the plugins runtime for marketplace refresh", async () => {
74103
const runPluginMarketplaceRefreshCommand = vi.fn().mockResolvedValue(undefined);
75104
vi.doMock("./plugins-cli.runtime.js", () => ({
105+
runPluginMarketplaceEntriesCommand: vi.fn(),
76106
runPluginMarketplaceListCommand: vi.fn(),
77107
runPluginMarketplaceRefreshCommand,
78108
runPluginsDisableCommand: vi.fn(),
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Covers the hosted OpenClaw marketplace feed entries command.
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const mocks = vi.hoisted(() => {
5+
const defaultRuntime = {
6+
error: vi.fn(),
7+
exit: vi.fn((code: number) => {
8+
throw new Error(`exit ${code}`);
9+
}),
10+
log: vi.fn(),
11+
writeJson: vi.fn(),
12+
};
13+
return {
14+
defaultRuntime,
15+
getRuntimeConfig: vi.fn(),
16+
loadConfiguredHostedOfficialExternalPluginCatalogEntries: vi.fn(),
17+
};
18+
});
19+
20+
vi.mock("../config/config.js", () => ({
21+
assertConfigWriteAllowedInCurrentMode: vi.fn(),
22+
getRuntimeConfig: mocks.getRuntimeConfig,
23+
readConfigFileSnapshot: vi.fn(),
24+
replaceConfigFile: vi.fn(),
25+
}));
26+
27+
vi.mock("../runtime.js", () => ({
28+
defaultRuntime: mocks.defaultRuntime,
29+
}));
30+
31+
vi.mock("../plugins/official-external-plugin-catalog.js", async (importOriginal) => {
32+
const actual =
33+
await importOriginal<typeof import("../plugins/official-external-plugin-catalog.js")>();
34+
return {
35+
...actual,
36+
loadConfiguredHostedOfficialExternalPluginCatalogEntries:
37+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries,
38+
};
39+
});
40+
41+
describe("plugins marketplace entries", () => {
42+
beforeEach(() => {
43+
mocks.defaultRuntime.error.mockClear();
44+
mocks.defaultRuntime.exit.mockClear();
45+
mocks.defaultRuntime.log.mockClear();
46+
mocks.defaultRuntime.writeJson.mockClear();
47+
mocks.getRuntimeConfig.mockReset();
48+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockReset();
49+
});
50+
51+
it("lists entries from the configured marketplace feed as JSON", async () => {
52+
const config = {
53+
marketplaces: {
54+
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
55+
sources: { "acme-npm": { type: "npm" as const } },
56+
},
57+
};
58+
mocks.getRuntimeConfig.mockReturnValue(config);
59+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
60+
source: "hosted-snapshot",
61+
entries: [
62+
{
63+
name: "@acme/calendar",
64+
version: "1.2.3",
65+
kind: "plugin",
66+
state: "available",
67+
publisher: { trust: "official" },
68+
install: {
69+
candidates: [{ sourceRef: "acme-npm", package: "@acme/calendar", version: "1.2.3" }],
70+
},
71+
openclaw: {
72+
plugin: { id: "acme-calendar", label: "Acme Calendar" },
73+
},
74+
},
75+
],
76+
feed: {
77+
schemaVersion: 1,
78+
id: "acme-marketplace",
79+
generatedAt: "2026-06-23T00:00:00.000Z",
80+
sequence: 7,
81+
entries: [],
82+
},
83+
metadata: {
84+
url: "https://packages.acme.example/openclaw/feed",
85+
status: 200,
86+
checksum: "feed-sha",
87+
},
88+
snapshot: {
89+
body: "{}",
90+
metadata: {
91+
url: "https://packages.acme.example/openclaw/feed",
92+
status: 200,
93+
checksum: "feed-sha",
94+
},
95+
savedAt: "2026-06-23T01:02:03.000Z",
96+
},
97+
error: "hosted catalog feed offline mode",
98+
});
99+
100+
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
101+
await runPluginMarketplaceEntriesCommand({ feedProfile: "acme", offline: true, json: true });
102+
103+
expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith(
104+
config,
105+
{ feedProfile: "acme", offline: true },
106+
);
107+
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
108+
expect.objectContaining({
109+
source: "hosted-snapshot",
110+
entryCount: 1,
111+
entries: [
112+
expect.objectContaining({
113+
id: "acme-calendar",
114+
label: "Acme Calendar",
115+
name: "@acme/calendar",
116+
version: "1.2.3",
117+
install: expect.objectContaining({ npmSpec: "@acme/[email protected]" }),
118+
}),
119+
],
120+
}),
121+
);
122+
});
123+
124+
it("redacts query-bearing feed URLs from entries output", async () => {
125+
mocks.getRuntimeConfig.mockReturnValue({});
126+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
127+
source: "bundled-fallback",
128+
entries: [],
129+
error:
130+
"hosted catalog feed fetch failed for https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
131+
metadata: {
132+
url: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
133+
status: 503,
134+
},
135+
});
136+
137+
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
138+
await runPluginMarketplaceEntriesCommand({
139+
feedUrl: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
140+
json: true,
141+
});
142+
143+
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
144+
expect.objectContaining({
145+
metadata: expect.objectContaining({ url: "https://clawhub.ai/v1/feeds/plugins" }),
146+
error: "hosted catalog feed fetch failed for https://clawhub.ai/v1/feeds/plugins",
147+
}),
148+
);
149+
150+
mocks.defaultRuntime.writeJson.mockClear();
151+
mocks.defaultRuntime.log.mockClear();
152+
153+
await runPluginMarketplaceEntriesCommand({
154+
feedUrl: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
155+
});
156+
157+
const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n");
158+
expect(output).toContain("https://clawhub.ai/v1/feeds/plugins");
159+
expect(output).not.toContain("token=secret");
160+
expect(output).not.toContain("#frag");
161+
});
162+
163+
it("prints bundled fallback entries without failing", async () => {
164+
mocks.getRuntimeConfig.mockReturnValue({});
165+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
166+
source: "bundled-fallback",
167+
entries: [
168+
{
169+
name: "@openclaw/acpx",
170+
openclaw: {
171+
plugin: { id: "acpx", label: "ACP" },
172+
install: {
173+
clawhubSpec: "clawhub:@openclaw/acpx",
174+
npmSpec: "@openclaw/acpx",
175+
defaultChoice: "npm",
176+
},
177+
},
178+
},
179+
],
180+
error: "hosted catalog feed offline mode",
181+
});
182+
183+
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
184+
await runPluginMarketplaceEntriesCommand({ offline: true });
185+
186+
const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n");
187+
expect(output).toContain("bundled fallback");
188+
expect(output).toContain("acpx");
189+
expect(output).toContain("@openclaw/acpx");
190+
expect(output).not.toContain("clawhub:@openclaw/acpx");
191+
expect(output).toContain("hosted catalog feed offline mode");
192+
expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled();
193+
});
194+
});

0 commit comments

Comments
 (0)