diff --git a/src/cli/plugins-cli.marketplace-refresh.test.ts b/src/cli/plugins-cli.marketplace-refresh.test.ts index 5f7306d62779..b763fdb003ce 100644 --- a/src/cli/plugins-cli.marketplace-refresh.test.ts +++ b/src/cli/plugins-cli.marketplace-refresh.test.ts @@ -15,9 +15,11 @@ const mocks = vi.hoisted(() => { writeJson: vi.fn(), }; return { + clearManagedPluginOfficialCatalogCache: vi.fn(), defaultRuntime, getRuntimeConfig: vi.fn(), loadConfiguredHostedOfficialExternalPluginCatalogEntries: vi.fn(), + notifyGatewayPluginMetadataChanged: vi.fn(), }; }); @@ -37,6 +39,14 @@ vi.mock("../plugins/official-external-plugin-catalog.js", () => ({ mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries, })); +vi.mock("../plugins/management-service.js", () => ({ + clearManagedPluginOfficialCatalogCache: mocks.clearManagedPluginOfficialCatalogCache, +})); + +vi.mock("./plugins-update-gateway-signal.js", () => ({ + notifyGatewayPluginMetadataChanged: mocks.notifyGatewayPluginMetadataChanged, +})); + async function createTimelinePath(): Promise { const dir = await mkdtemp(path.join(tmpdir(), "openclaw-marketplace-refresh-")); return path.join(dir, "timeline.jsonl"); @@ -58,6 +68,8 @@ describe("plugins marketplace refresh", () => { mocks.defaultRuntime.writeJson.mockClear(); mocks.getRuntimeConfig.mockReset(); mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockReset(); + mocks.clearManagedPluginOfficialCatalogCache.mockReset(); + mocks.notifyGatewayPluginMetadataChanged.mockReset().mockResolvedValue(true); vi.unstubAllEnvs(); }); @@ -87,6 +99,7 @@ describe("plugins marketplace refresh", () => { expectedSha256: "feed-sha", requireSnapshotWrite: true, }); + expect(mocks.notifyGatewayPluginMetadataChanged).toHaveBeenCalledWith(config); expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith({ source: "hosted", entries: 2, @@ -185,6 +198,65 @@ describe("plugins marketplace refresh", () => { const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n"); expect(output).toContain("bundled fallback"); expect(output).toContain("hosted catalog feed returned HTTP 503"); + expect(mocks.notifyGatewayPluginMetadataChanged).not.toHaveBeenCalled(); + expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled(); + }); + + it("keeps pinned snapshot JSON clean when the Gateway cannot refresh", async () => { + const config = {}; + mocks.getRuntimeConfig.mockReturnValue(config); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue( + createHostedMarketplaceFeedFixture({ source: "hosted-snapshot" }), + ); + mocks.notifyGatewayPluginMetadataChanged.mockResolvedValue(false); + + const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js"); + await expect( + runPluginMarketplaceRefreshCommand({ expectedSha256: "sha256:expected", json: true }), + ).rejects.toThrow("exit 1"); + + expect(mocks.notifyGatewayPluginMetadataChanged).toHaveBeenCalledWith(config); + expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledOnce(); + expect(mocks.defaultRuntime.log).not.toHaveBeenCalled(); + expect(mocks.defaultRuntime.error.mock.calls.map(([message]) => message)).toEqual([ + expect.stringContaining('Run "openclaw gateway restart" to apply the current catalog state.'), + "Pinned marketplace feed refresh did not accept a fresh hosted payload (source: hosted-snapshot).", + ]); + expect(mocks.defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("keeps a hosted refresh successful and prints restart guidance when the Gateway is offline", async () => { + mocks.getRuntimeConfig.mockReturnValue({}); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue( + createHostedMarketplaceFeedFixture(), + ); + mocks.notifyGatewayPluginMetadataChanged.mockResolvedValue(false); + + const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js"); + await runPluginMarketplaceRefreshCommand({}); + + expect(mocks.defaultRuntime.log).toHaveBeenCalledWith( + expect.stringContaining('Run "openclaw gateway restart" to apply the current catalog state.'), + ); + expect(mocks.defaultRuntime.error).not.toHaveBeenCalled(); + expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled(); + }); + + it("keeps JSON stdout clean when an offline Gateway needs a restart", async () => { + mocks.getRuntimeConfig.mockReturnValue({}); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue( + createHostedMarketplaceFeedFixture(), + ); + mocks.notifyGatewayPluginMetadataChanged.mockResolvedValue(false); + + const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js"); + await runPluginMarketplaceRefreshCommand({ json: true }); + + expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledOnce(); + expect(mocks.defaultRuntime.log).not.toHaveBeenCalled(); + expect(mocks.defaultRuntime.error).toHaveBeenCalledWith( + expect.stringContaining('Run "openclaw gateway restart" to apply the current catalog state.'), + ); expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled(); }); diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 1d1c44761cb7..917d9807ba3c 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -776,6 +776,9 @@ function formatPinnedMarketplaceRefreshFailure(payload: MarketplaceRefreshPayloa return `Pinned marketplace feed refresh did not accept a fresh hosted payload (source: ${payload.source}).`; } +const MARKETPLACE_GATEWAY_RESTART_GUIDANCE = + 'The running Gateway could not refresh its marketplace catalog. Run "openclaw gateway restart" to apply the current catalog state.'; + /** List entries from the configured OpenClaw marketplace feed. */ export async function runPluginMarketplaceEntriesCommand( opts: PluginMarketplaceEntriesOptions, @@ -851,6 +854,13 @@ export async function runPluginMarketplaceRefreshCommand( const { clearManagedPluginOfficialCatalogCache } = await import("../plugins/management-service.js"); clearManagedPluginOfficialCatalogCache(); + let gatewayRefreshed = true; + // Reused snapshots can lose install authority as they age, so their Gateway projection is stale too. + if (result.source !== "bundled-fallback") { + const { notifyGatewayPluginMetadataChanged } = + await import("./plugins-update-gateway-signal.js"); + gatewayRefreshed = await notifyGatewayPluginMetadataChanged(cfg); + } const payload = sanitizeMarketplaceRefreshPayload(buildMarketplaceRefreshPayload(result), { feedUrl: opts.feedUrl, }); @@ -869,6 +879,9 @@ export async function runPluginMarketplaceRefreshCommand( if (opts.json) { defaultRuntime.writeJson(payload); + if (!gatewayRefreshed) { + defaultRuntime.error(MARKETPLACE_GATEWAY_RESTART_GUIDANCE); + } if (failedPinnedRefresh) { defaultRuntime.error(formatPinnedMarketplaceRefreshFailure(payload)); return defaultRuntime.exit(1); @@ -877,6 +890,9 @@ export async function runPluginMarketplaceRefreshCommand( } const lines = formatMarketplaceFeedLines(payload, { includeChecksum: true }); + if (!gatewayRefreshed) { + lines.push("", theme.warn(MARKETPLACE_GATEWAY_RESTART_GUIDANCE)); + } defaultRuntime.log(lines.join("\n")); if (failedPinnedRefresh) { defaultRuntime.error(formatPinnedMarketplaceRefreshFailure(payload));