Skip to content

Commit 1614070

Browse files
committed
Add marketplace feed refresh command
1 parent 2f851ec commit 1614070

7 files changed

Lines changed: 743 additions & 10 deletions

docs/cli/plugins.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ openclaw plugins update <id-or-npm-spec>
5454
openclaw plugins update --all
5555
openclaw plugins marketplace list <marketplace>
5656
openclaw plugins marketplace list <marketplace> --json
57+
openclaw plugins marketplace refresh
58+
openclaw plugins marketplace refresh --feed-profile clawhub-public --json
59+
openclaw plugins marketplace refresh --feed-url https://clawhub.ai/v1/feeds/plugins --expected-sha256 <sha256>
5760
openclaw plugins init my-tool --name "My Tool"
5861
openclaw plugins init my-provider --name "My Provider" --type provider
5962
openclaw plugins init my-provider --name "My Provider" --type provider --directory ./my-provider
@@ -521,10 +524,26 @@ Use `plugins registry` to inspect whether the persisted registry is present, cur
521524
```bash
522525
openclaw plugins marketplace list <source>
523526
openclaw plugins marketplace list <source> --json
527+
openclaw plugins marketplace refresh
528+
openclaw plugins marketplace refresh --feed-profile <name>
529+
openclaw plugins marketplace refresh --feed-url <url>
530+
openclaw plugins marketplace refresh --expected-sha256 <sha256> --json
524531
```
525532

526533
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.
527534

535+
Marketplace refresh loads a hosted OpenClaw marketplace feed and persists the
536+
validated response as the local hosted-feed snapshot. Without options, it uses
537+
the configured default feed profile. Use `--feed-profile <name>` to refresh a
538+
specific configured profile, `--feed-url <url>` to refresh an explicit hosted
539+
feed URL, `--expected-sha256 <sha256>` to require a matching payload checksum
540+
(`sha256:<hex>` or a bare 64-character hex digest), and `--json` for
541+
machine-readable output. Explicit hosted feed URLs must not include
542+
credentials, query strings, or fragments. Unpinned refreshes can report a
543+
hosted snapshot or bundled fallback result without failing the command. Pinned
544+
refreshes fail unless they accept a fresh hosted payload, and successful hosted
545+
refreshes fail if OpenClaw cannot persist the validated snapshot.
546+
528547
## Related
529548

530549
- [Building plugins](/plugins/building-plugins)

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe("plugins cli lazy runtime boundary", () => {
1818
runtimeLoaded();
1919
return {
2020
runPluginMarketplaceListCommand: vi.fn(),
21+
runPluginMarketplaceRefreshCommand: vi.fn(),
2122
runPluginsDisableCommand: vi.fn(),
2223
runPluginsDoctorCommand: vi.fn(),
2324
runPluginsEnableCommand: vi.fn(),
@@ -50,6 +51,7 @@ describe("plugins cli lazy runtime boundary", () => {
5051
runtimeLoaded();
5152
return {
5253
runPluginMarketplaceListCommand: vi.fn(),
54+
runPluginMarketplaceRefreshCommand: vi.fn(),
5355
runPluginsDisableCommand: vi.fn(),
5456
runPluginsDoctorCommand: vi.fn(),
5557
runPluginsEnableCommand: vi.fn(),
@@ -67,4 +69,39 @@ describe("plugins cli lazy runtime boundary", () => {
6769
expect(runtimeLoaded).toHaveBeenCalledTimes(1);
6870
expect(runPluginsRegistryCommand).toHaveBeenCalledWith(expect.objectContaining({ json: true }));
6971
});
72+
73+
it("loads the plugins runtime for marketplace refresh", async () => {
74+
const runPluginMarketplaceRefreshCommand = vi.fn().mockResolvedValue(undefined);
75+
vi.doMock("./plugins-cli.runtime.js", () => ({
76+
runPluginMarketplaceListCommand: vi.fn(),
77+
runPluginMarketplaceRefreshCommand,
78+
runPluginsDisableCommand: vi.fn(),
79+
runPluginsDoctorCommand: vi.fn(),
80+
runPluginsEnableCommand: vi.fn(),
81+
runPluginsInstallAction: vi.fn(),
82+
runPluginsRegistryCommand: vi.fn(),
83+
}));
84+
85+
const { registerPluginsCli } = await import("./plugins-cli.js");
86+
const program = new Command();
87+
registerPluginsCli(program);
88+
89+
await program.parseAsync(
90+
[
91+
"plugins",
92+
"marketplace",
93+
"refresh",
94+
"--feed-profile",
95+
"acme",
96+
"--expected-sha256",
97+
"abc123",
98+
"--json",
99+
],
100+
{ from: "user" },
101+
);
102+
103+
expect(runPluginMarketplaceRefreshCommand).toHaveBeenCalledWith(
104+
expect.objectContaining({ feedProfile: "acme", expectedSha256: "abc123", json: true }),
105+
);
106+
});
70107
});
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Covers the hosted OpenClaw marketplace feed refresh 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", () => ({
32+
loadConfiguredHostedOfficialExternalPluginCatalogEntries:
33+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries,
34+
}));
35+
36+
describe("plugins marketplace refresh", () => {
37+
beforeEach(() => {
38+
mocks.defaultRuntime.error.mockClear();
39+
mocks.defaultRuntime.exit.mockClear();
40+
mocks.defaultRuntime.log.mockClear();
41+
mocks.defaultRuntime.writeJson.mockClear();
42+
mocks.getRuntimeConfig.mockReset();
43+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockReset();
44+
});
45+
46+
it("refreshes the configured marketplace feed and prints JSON", async () => {
47+
const config = {
48+
marketplaces: {
49+
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
50+
},
51+
};
52+
mocks.getRuntimeConfig.mockReturnValue(config);
53+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
54+
source: "hosted",
55+
entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }],
56+
feed: {
57+
schemaVersion: 1,
58+
id: "acme-marketplace",
59+
generatedAt: "2026-06-23T00:00:00.000Z",
60+
sequence: 7,
61+
entries: [],
62+
},
63+
metadata: {
64+
url: "https://packages.acme.example/openclaw/feed",
65+
status: 200,
66+
checksum: "feed-sha",
67+
etag: '"abc"',
68+
},
69+
});
70+
71+
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
72+
await runPluginMarketplaceRefreshCommand({
73+
feedProfile: "acme",
74+
expectedSha256: "feed-sha",
75+
json: true,
76+
});
77+
78+
expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith(
79+
config,
80+
{ feedProfile: "acme", expectedSha256: "feed-sha", requireSnapshotWrite: true },
81+
);
82+
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith({
83+
source: "hosted",
84+
entries: 2,
85+
feed: {
86+
id: "acme-marketplace",
87+
generatedAt: "2026-06-23T00:00:00.000Z",
88+
sequence: 7,
89+
},
90+
metadata: {
91+
url: "https://packages.acme.example/openclaw/feed",
92+
status: 200,
93+
checksum: "feed-sha",
94+
etag: '"abc"',
95+
},
96+
});
97+
});
98+
99+
it("normalizes bare SHA-256 pins before refreshing", async () => {
100+
const config = {
101+
marketplaces: {
102+
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
103+
},
104+
};
105+
mocks.getRuntimeConfig.mockReturnValue(config);
106+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
107+
source: "hosted",
108+
entries: [{ name: "@acme/calendar" }],
109+
feed: {
110+
schemaVersion: 1,
111+
id: "acme-marketplace",
112+
generatedAt: "2026-06-23T00:00:00.000Z",
113+
sequence: 7,
114+
entries: [],
115+
},
116+
metadata: {
117+
url: "https://packages.acme.example/openclaw/feed",
118+
status: 200,
119+
checksum: "sha256:abcdef",
120+
},
121+
});
122+
123+
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
124+
await runPluginMarketplaceRefreshCommand({
125+
feedProfile: "acme",
126+
expectedSha256: "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789",
127+
json: true,
128+
});
129+
130+
expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith(
131+
config,
132+
{
133+
feedProfile: "acme",
134+
expectedSha256: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
135+
requireSnapshotWrite: true,
136+
},
137+
);
138+
139+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockClear();
140+
141+
await runPluginMarketplaceRefreshCommand({
142+
feedProfile: "acme",
143+
expectedSha256: "sha256:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789",
144+
json: true,
145+
});
146+
147+
expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith(
148+
config,
149+
{
150+
feedProfile: "acme",
151+
expectedSha256: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
152+
requireSnapshotWrite: true,
153+
},
154+
);
155+
});
156+
157+
it("reports bundled fallback without failing the command", async () => {
158+
mocks.getRuntimeConfig.mockReturnValue({});
159+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
160+
source: "bundled-fallback",
161+
entries: [{ name: "@openclaw/acpx" }],
162+
error: "hosted catalog feed returned HTTP 503",
163+
metadata: {
164+
url: "https://clawhub.ai/v1/feeds/plugins",
165+
status: 503,
166+
},
167+
});
168+
169+
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
170+
await runPluginMarketplaceRefreshCommand({});
171+
172+
const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n");
173+
expect(output).toContain("bundled fallback");
174+
expect(output).toContain("hosted catalog feed returned HTTP 503");
175+
expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled();
176+
});
177+
178+
it("redacts query-bearing feed URLs from refresh output", async () => {
179+
mocks.getRuntimeConfig.mockReturnValue({});
180+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
181+
source: "bundled-fallback",
182+
entries: [{ name: "@openclaw/acpx" }],
183+
error:
184+
"hosted catalog feed fetch failed for https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
185+
metadata: {
186+
url: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
187+
status: 503,
188+
},
189+
});
190+
191+
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
192+
await runPluginMarketplaceRefreshCommand({
193+
feedUrl: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
194+
json: true,
195+
});
196+
197+
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
198+
expect.objectContaining({
199+
metadata: expect.objectContaining({ url: "https://clawhub.ai/v1/feeds/plugins" }),
200+
error: "hosted catalog feed fetch failed for https://clawhub.ai/v1/feeds/plugins",
201+
}),
202+
);
203+
204+
mocks.defaultRuntime.writeJson.mockClear();
205+
mocks.defaultRuntime.log.mockClear();
206+
207+
await runPluginMarketplaceRefreshCommand({
208+
feedUrl: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
209+
});
210+
211+
const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n");
212+
expect(output).toContain("https://clawhub.ai/v1/feeds/plugins");
213+
expect(output).not.toContain("token=secret");
214+
expect(output).not.toContain("#frag");
215+
});
216+
217+
it("fails checksum-pinned refreshes that fall back", async () => {
218+
mocks.getRuntimeConfig.mockReturnValue({});
219+
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
220+
source: "bundled-fallback",
221+
entries: [{ name: "@openclaw/acpx" }],
222+
error: "hosted catalog feed checksum mismatch: expected sha256:expected",
223+
metadata: {
224+
url: "https://clawhub.ai/v1/feeds/plugins",
225+
status: 200,
226+
checksum: "sha256:actual",
227+
},
228+
});
229+
230+
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
231+
await expect(
232+
runPluginMarketplaceRefreshCommand({ expectedSha256: "sha256:expected", json: true }),
233+
).rejects.toThrow("exit 1");
234+
235+
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
236+
expect.objectContaining({ source: "bundled-fallback" }),
237+
);
238+
expect(mocks.defaultRuntime.error).toHaveBeenCalledWith(
239+
"Pinned marketplace feed refresh did not accept a fresh hosted payload (source: bundled-fallback).",
240+
);
241+
expect(mocks.defaultRuntime.exit).toHaveBeenCalledWith(1);
242+
});
243+
});

0 commit comments

Comments
 (0)