mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
co-authored by
Tom Huang
parent
fbabb27143
commit
0df9176c2f
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user