feat(plugins): marketplace registry + od marketplace CLI verbs

Plan §3.B4 / spec §6 / §7.2 / §16 Phase 3 entry slice.

The storage half of federated catalogs:

- New apps/daemon/src/plugins/marketplaces.ts owns add / list / get /
  refresh / remove / set-trust over the pre-existing plugin_marketplaces
  SQLite table. Uses parseMarketplace from @open-design/plugin-runtime so
  validation matches the schema in docs/schemas/open-design.marketplace.v1.json.
  Default trust tier is 'restricted' (discovery only) per spec §9.
- HTTP routes:
  * GET    /api/marketplaces
  * POST   /api/marketplaces                      (body { url, trust? })
  * GET    /api/marketplaces/:id
  * DELETE /api/marketplaces/:id
  * POST   /api/marketplaces/:id/refresh
  * POST   /api/marketplaces/:id/trust            (body { trust })
  * GET    /api/marketplaces/:id/plugins          (catalog passthrough)
- CLI verbs: `od marketplace add/list/info/refresh/remove/trust`.
  --trust accepts 'trusted', 'restricted', or 'official'.

Phase 3 follow-up wires `od plugin install <name>` resolution + the trust
UI on top; this commit is the data-layer half so the CLI can already
register and inspect catalogs.

Daemon tests: 1438 → 1443 (added plugins-marketplaces with 5 cases).

Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
Cursor Agent
2026-05-09 11:37:12 +00:00
co-authored by Tom Huang
parent fbabb27143
commit 0df9176c2f
5 changed files with 560 additions and 0 deletions
+104
View File
@@ -78,6 +78,7 @@ const PLUGIN_STRING_FLAGS = new Set([
'capabilities',
'grant-caps',
'before',
'trust',
]);
const PLUGIN_BOOLEAN_FLAGS = new Set([
'help',
@@ -111,6 +112,7 @@ const SUBCOMMAND_MAP = {
research: runResearch,
plugin: runPlugin,
ui: runUi,
marketplace: runMarketplace,
};
if (argv[0] === 'mcp' && argv[1] === 'live-artifacts') {
@@ -822,6 +824,108 @@ async function runPlugin(args) {
}
}
// Plan §3.B4 / spec §6: `od marketplace …` minimum verbs. Add / list /
// refresh / remove / trust. The Phase 3 follow-up wires
// `od plugin install <name>` resolution through these catalogs.
async function runMarketplace(args) {
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
console.log(`Usage:
od marketplace add <url> [--trust trusted|restricted] Register a federated catalog.
od marketplace list List registered marketplaces.
od marketplace info <id> Inspect one marketplace + cached manifest.
od marketplace refresh <id> Re-fetch the manifest.
od marketplace remove <id> Forget a marketplace.
od marketplace trust <id> [--trust trusted|restricted|official]
Update the marketplace trust tier.
Common options:
--daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456).
--json Emit raw JSON (suitable for scripts).`);
process.exit(args.length === 0 ? 2 : 0);
}
const sub = args[0];
const rest = args.slice(1);
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
switch (sub) {
case 'list': {
const resp = await fetch(`${base}/api/marketplaces`);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) return structuredHttpFailure(resp);
if (flags.json) {
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
return;
}
const rows = data?.marketplaces ?? [];
if (rows.length === 0) {
console.log('No marketplaces registered. Run `od marketplace add <url>`.');
return;
}
for (const m of rows) {
console.log(`${m.id} trust=${m.trust} url=${m.url}`);
}
return;
}
case 'add': {
const url = rest.find((a) => !a.startsWith('-'));
if (!url) {
console.error('Usage: od marketplace add <url> [--trust trusted|restricted]');
process.exit(2);
}
const trust = flags.trust ?? 'restricted';
const resp = await fetch(`${base}/api/marketplaces`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url, trust }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
console.error(`add failed: ${resp.status} ${JSON.stringify(data)}`);
process.exit(1);
}
console.log(`[marketplace] added ${data.id} (${data.url}) trust=${data.trust}`);
return;
}
case 'info':
case 'refresh':
case 'remove':
case 'trust': {
const id = rest.find((a) => !a.startsWith('-')
&& a !== flags.trust);
if (!id) {
console.error(`Usage: od marketplace ${sub} <id>`);
process.exit(2);
}
let url;
let method = 'GET';
let body;
if (sub === 'info') url = `${base}/api/marketplaces/${encodeURIComponent(id)}`;
else if (sub === 'refresh') { url = `${base}/api/marketplaces/${encodeURIComponent(id)}/refresh`; method = 'POST'; }
else if (sub === 'remove') { url = `${base}/api/marketplaces/${encodeURIComponent(id)}`; method = 'DELETE'; }
else if (sub === 'trust') {
const trust = flags.trust ?? 'trusted';
url = `${base}/api/marketplaces/${encodeURIComponent(id)}/trust`;
method = 'POST';
body = JSON.stringify({ trust });
}
const resp = await fetch(url, {
method,
...(body ? { headers: { 'content-type': 'application/json' }, body } : {}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
console.error(`${sub} failed: ${resp.status} ${JSON.stringify(data)}`);
process.exit(1);
}
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
return;
}
default:
console.error(`unknown subcommand: od marketplace ${sub}`);
process.exit(2);
}
}
// Plan §3.A5 / spec §16 Phase 5: operator escape hatch for snapshot GC.
// Two subcommands:
// - `od plugin snapshots list [--project <id>]` — list snapshots
+1
View File
@@ -7,6 +7,7 @@ export * from './connector-gate.js';
export * from './doctor.js';
export * from './installer.js';
export * from './persistence.js';
export * from './marketplaces.js';
export * from './pipeline.js';
export * from './pipeline-runner.js';
export * from './registry.js';
+231
View File
@@ -0,0 +1,231 @@
// Marketplace registry — plan §3.B4 / spec §6 / §7 / §11.5 / §16 Phase 3
// (entry slice).
//
// Stores user-configured federated catalog indexes in
// `plugin_marketplaces`. The actual `od plugin install <name>` resolution
// through these catalogs lands in Phase 3 alongside the trust UI; this
// module is the storage + refresh half so the desktop / CLI can already
// register and inspect catalogs.
//
// We intentionally treat the catalog body as opaque JSON in v1 — Zod
// validation lives in `@open-design/plugin-runtime`'s parser and we only
// store what the parser returns. Trust default mirrors §9: a freshly
// added user-supplied marketplace is `restricted` (discovery only)
// unless `--trust` is passed.
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import {
parseMarketplace,
type MarketplaceParseResult,
} from '@open-design/plugin-runtime';
import type { MarketplaceManifest } from '@open-design/contracts';
type SqliteDb = Database.Database;
export type MarketplaceTrustTier = 'official' | 'trusted' | 'restricted';
export interface MarketplaceRow {
id: string;
url: string;
trust: MarketplaceTrustTier;
manifest: MarketplaceManifest;
addedAt: number;
refreshedAt: number;
}
export interface AddMarketplaceInput {
url: string;
// Pluggable HTTPS fetcher; tests inject a stub. Production injects the
// global fetch.
fetcher?: (url: string) => Promise<{ ok: boolean; status: number; text: () => Promise<string> }>;
trust?: MarketplaceTrustTier;
}
export interface AddMarketplaceResult {
ok: true;
row: MarketplaceRow;
warnings: string[];
}
export interface AddMarketplaceFailure {
ok: false;
status: number;
message: string;
errors?: string[];
}
const HTTPS_RE = /^https:\/\//i;
export async function addMarketplace(
db: SqliteDb,
input: AddMarketplaceInput,
): Promise<AddMarketplaceResult | AddMarketplaceFailure> {
if (!HTTPS_RE.test(input.url)) {
return {
ok: false,
status: 400,
message: 'marketplace url must use https://',
};
}
const fetcher = input.fetcher ?? defaultFetcher;
let resp;
try {
resp = await fetcher(input.url);
} catch (err) {
return {
ok: false,
status: 502,
message: `Fetch failed: ${(err as Error).message ?? String(err)}`,
};
}
if (!resp.ok) {
return {
ok: false,
status: 502,
message: `Marketplace fetch returned ${resp.status}`,
};
}
const text = await resp.text();
const parsed: MarketplaceParseResult = parseMarketplace(text);
if (!parsed.ok) {
return {
ok: false,
status: 422,
message: 'marketplace manifest failed validation',
errors: parsed.errors,
};
}
const id = randomUUID();
const now = Date.now();
const trust = input.trust ?? 'restricted';
db.prepare(
`INSERT INTO plugin_marketplaces (id, url, trust, manifest_json, added_at, refreshed_at)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, input.url, trust, text, now, now);
return {
ok: true,
row: { id, url: input.url, trust, manifest: parsed.manifest, addedAt: now, refreshedAt: now },
warnings: [],
};
}
export function listMarketplaces(db: SqliteDb): MarketplaceRow[] {
const rows = db
.prepare(`SELECT id, url, trust, manifest_json, added_at, refreshed_at FROM plugin_marketplaces ORDER BY added_at ASC`)
.all() as Array<{
id: string;
url: string;
trust: MarketplaceTrustTier;
manifest_json: string;
added_at: number;
refreshed_at: number;
}>;
return rows.map((r) => ({
id: r.id,
url: r.url,
trust: r.trust,
manifest: safeParseManifest(r.manifest_json),
addedAt: r.added_at,
refreshedAt: r.refreshed_at,
}));
}
export function getMarketplace(db: SqliteDb, id: string): MarketplaceRow | null {
const row = db
.prepare(`SELECT id, url, trust, manifest_json, added_at, refreshed_at FROM plugin_marketplaces WHERE id = ?`)
.get(id) as
| undefined
| {
id: string;
url: string;
trust: MarketplaceTrustTier;
manifest_json: string;
added_at: number;
refreshed_at: number;
};
if (!row) return null;
return {
id: row.id,
url: row.url,
trust: row.trust,
manifest: safeParseManifest(row.manifest_json),
addedAt: row.added_at,
refreshedAt: row.refreshed_at,
};
}
export function removeMarketplace(db: SqliteDb, id: string): boolean {
const info = db.prepare(`DELETE FROM plugin_marketplaces WHERE id = ?`).run(id);
return info.changes > 0;
}
export function setMarketplaceTrust(
db: SqliteDb,
id: string,
trust: MarketplaceTrustTier,
): MarketplaceRow | null {
const info = db.prepare(`UPDATE plugin_marketplaces SET trust = ? WHERE id = ?`).run(trust, id);
if (info.changes === 0) return null;
return getMarketplace(db, id);
}
export interface RefreshMarketplaceResult {
ok: true;
row: MarketplaceRow;
}
export async function refreshMarketplace(
db: SqliteDb,
id: string,
fetcher?: AddMarketplaceInput['fetcher'],
): Promise<RefreshMarketplaceResult | AddMarketplaceFailure> {
const existing = getMarketplace(db, id);
if (!existing) {
return { ok: false, status: 404, message: `marketplace ${id} not found` };
}
const useFetcher = fetcher ?? defaultFetcher;
let resp;
try {
resp = await useFetcher(existing.url);
} catch (err) {
return { ok: false, status: 502, message: `Fetch failed: ${(err as Error).message ?? String(err)}` };
}
if (!resp.ok) return { ok: false, status: 502, message: `Marketplace fetch returned ${resp.status}` };
const text = await resp.text();
const parsed = parseMarketplace(text);
if (!parsed.ok) {
return { ok: false, status: 422, message: 'marketplace manifest failed validation', errors: parsed.errors };
}
const now = Date.now();
db.prepare(`UPDATE plugin_marketplaces SET manifest_json = ?, refreshed_at = ? WHERE id = ?`)
.run(text, now, id);
return {
ok: true,
row: { ...existing, manifest: parsed.manifest, refreshedAt: now },
};
}
async function defaultFetcher(url: string) {
const response = await fetch(url, { redirect: 'follow' });
return {
ok: response.ok,
status: response.status,
text: () => response.text(),
};
}
function safeParseManifest(raw: string): MarketplaceManifest {
try {
const parsed = parseMarketplace(raw);
if (parsed.ok) return parsed.manifest;
} catch {
// fall through
}
// Last-resort fallback: return a minimal shape so the caller doesn't
// explode if a database row was stored before a schema patch.
return {
name: 'unknown',
plugins: [],
} as MarketplaceManifest;
}
+97
View File
@@ -3385,6 +3385,103 @@ export async function startServer({
}
});
// Plan §3.B4 / spec §6: marketplace registry minimum verbs.
// Phase 3 layers in `od plugin install <name>` resolution + the trust
// UI on top; this route set is the storage half.
app.get('/api/marketplaces', async (_req, res) => {
try {
const { listMarketplaces } = await import('./plugins/marketplaces.js');
res.json({ marketplaces: listMarketplaces(db) });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.post('/api/marketplaces', async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const url = typeof body.url === 'string' ? body.url : '';
if (!url) return res.status(400).json({ error: 'url is required' });
const trust = body.trust === 'trusted' || body.trust === 'official' ? body.trust : 'restricted';
const { addMarketplace } = await import('./plugins/marketplaces.js');
const result = await addMarketplace(db, { url, trust });
if (!result.ok) {
return res.status(result.status).json({
error: { code: 'marketplace-add-failed', message: result.message, data: { errors: result.errors ?? [] } },
});
}
res.status(201).json(result.row);
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/marketplaces/:id', async (req, res) => {
try {
const { getMarketplace } = await import('./plugins/marketplaces.js');
const row = getMarketplace(db, req.params.id);
if (!row) return res.status(404).json({ error: 'marketplace not found' });
res.json(row);
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.delete('/api/marketplaces/:id', async (req, res) => {
try {
const { removeMarketplace } = await import('./plugins/marketplaces.js');
const ok = removeMarketplace(db, req.params.id);
if (!ok) return res.status(404).json({ error: 'marketplace not found' });
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.post('/api/marketplaces/:id/refresh', async (req, res) => {
try {
const { refreshMarketplace } = await import('./plugins/marketplaces.js');
const result = await refreshMarketplace(db, req.params.id);
if (!result.ok) {
return res.status(result.status).json({
error: { code: 'marketplace-refresh-failed', message: result.message, data: { errors: result.errors ?? [] } },
});
}
res.json(result.row);
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.post('/api/marketplaces/:id/trust', async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const trust = body.trust === 'trusted' || body.trust === 'restricted' || body.trust === 'official'
? body.trust
: null;
if (!trust) {
return res.status(400).json({ error: 'trust must be one of: trusted, restricted, official' });
}
const { setMarketplaceTrust } = await import('./plugins/marketplaces.js');
const row = setMarketplaceTrust(db, req.params.id, trust);
if (!row) return res.status(404).json({ error: 'marketplace not found' });
res.json(row);
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/marketplaces/:id/plugins', async (req, res) => {
try {
const { getMarketplace } = await import('./plugins/marketplaces.js');
const row = getMarketplace(db, req.params.id);
if (!row) return res.status(404).json({ error: 'marketplace not found' });
res.json({ plugins: row.manifest.plugins ?? [] });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// Plan §3.A5: list all applied snapshots; useful for `od plugin
// snapshots list` and the audit dashboard.
app.get('/api/applied-plugins', (_req, res) => {
@@ -0,0 +1,127 @@
// Plan §3.B4 — marketplaces add / list / refresh / remove / trust unit tests.
//
// Locks the storage half of the federated catalog story. The Phase 3
// follow-up will layer on `od plugin install <name>` resolution +
// trust UI, but the storage layout here is the contract that lookup
// will read against.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { migratePlugins } from '../src/plugins/persistence.js';
import {
addMarketplace,
getMarketplace,
listMarketplaces,
refreshMarketplace,
removeMarketplace,
setMarketplaceTrust,
} from '../src/plugins/marketplaces.js';
let db: Database.Database;
let tmpDir: string;
const VALID_MANIFEST = JSON.stringify({
name: 'test-marketplace',
metadata: { description: 'fixture', version: '1.0.0' },
plugins: [
{ name: 'sample-plugin', source: 'github:open-design/sample-plugin' },
],
});
function fixtureFetcher(text: string, ok = true) {
return async () => ({
ok,
status: ok ? 200 : 502,
text: async () => text,
});
}
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'od-mp-'));
db = new Database(path.join(tmpDir, 'test.sqlite'));
db.exec(`
CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE conversations (id TEXT PRIMARY KEY, project_id TEXT, title TEXT);
`);
migratePlugins(db);
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
describe('marketplaces', () => {
it('addMarketplace fetches, validates, stores, and returns the row', async () => {
const result = await addMarketplace(db, {
url: 'https://example.com/marketplace.json',
fetcher: fixtureFetcher(VALID_MANIFEST),
});
if (!result.ok) {
throw new Error(`expected ok: ${JSON.stringify(result)}`);
}
expect(result.row.url).toBe('https://example.com/marketplace.json');
expect(result.row.trust).toBe('restricted');
expect(result.row.manifest.plugins).toHaveLength(1);
expect(listMarketplaces(db)).toHaveLength(1);
});
it('addMarketplace rejects non-https urls', async () => {
const result = await addMarketplace(db, {
url: 'http://example.com/marketplace.json',
fetcher: fixtureFetcher(VALID_MANIFEST),
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(400);
expect(result.message).toMatch(/https/);
}
});
it('addMarketplace surfaces parse failures', async () => {
const result = await addMarketplace(db, {
url: 'https://example.com/marketplace.json',
fetcher: fixtureFetcher('{}'),
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(422);
}
});
it('refresh re-fetches and updates refreshed_at', async () => {
const added = await addMarketplace(db, {
url: 'https://example.com/marketplace.json',
fetcher: fixtureFetcher(VALID_MANIFEST),
});
if (!added.ok) throw new Error('add failed');
const updatedManifest = JSON.parse(VALID_MANIFEST);
updatedManifest.plugins.push({
name: 'new-plugin',
source: 'github:open-design/new-plugin',
});
const refreshed = await refreshMarketplace(
db,
added.row.id,
fixtureFetcher(JSON.stringify(updatedManifest)),
);
if (!refreshed.ok) throw new Error('refresh failed');
expect(refreshed.row.manifest.plugins).toHaveLength(2);
expect(refreshed.row.refreshedAt).toBeGreaterThanOrEqual(added.row.refreshedAt);
});
it('setMarketplaceTrust updates the trust tier and remove deletes the row', async () => {
const added = await addMarketplace(db, {
url: 'https://example.com/marketplace.json',
fetcher: fixtureFetcher(VALID_MANIFEST),
});
if (!added.ok) throw new Error('add failed');
const trusted = setMarketplaceTrust(db, added.row.id, 'trusted');
expect(trusted?.trust).toBe('trusted');
expect(removeMarketplace(db, added.row.id)).toBe(true);
expect(getMarketplace(db, added.row.id)).toBeNull();
});
});