feat(plugins): add registry protocol and enhance plugin management features

- Introduced the `@open-design/registry-protocol` package, enabling improved interactions with plugin registries.
- Updated the `typecheck` script in the daemon's `package.json` to include the new registry protocol.
- Enhanced the CLI with new flags and commands for better plugin management, including `yank` and additional marketplace functionalities.
- Implemented a plugin lockfile system to manage installed plugins and their versions, improving reliability during upgrades.
- Added new marketplace doctor functionality to validate plugin entries and ensure compliance with registry standards.

This update significantly enhances the plugin ecosystem by providing robust registry interactions and improved management capabilities.
This commit is contained in:
pftom
2026-05-14 08:55:36 +08:00
parent 56c264c9bd
commit 3b167b6921
52 changed files with 15369 additions and 165 deletions

View File

@@ -29,7 +29,7 @@
"dev": "pnpm run build && node dist/cli.js --no-open",
"start": "pnpm run build && node dist/cli.js",
"test": "vitest run -c vitest.config.ts",
"typecheck": "pnpm --filter @open-design/contracts build && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
"typecheck": "pnpm --filter @open-design/contracts build && pnpm --filter @open-design/registry-protocol build && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
@@ -37,6 +37,7 @@
"@open-design/contracts": "workspace:*",
"@open-design/platform": "workspace:*",
"@open-design/plugin-runtime": "workspace:*",
"@open-design/registry-protocol": "workspace:*",
"@open-design/sidecar": "workspace:*",
"@open-design/sidecar-proto": "workspace:*",
"better-sqlite3": "^12.9.0",

View File

@@ -84,6 +84,11 @@ const PLUGIN_STRING_FLAGS = new Set([
'before',
'trust',
'tag',
'policy',
'version',
'reason',
'catalog',
'host',
]);
const PLUGIN_BOOLEAN_FLAGS = new Set([
'help',
@@ -91,6 +96,7 @@ const PLUGIN_BOOLEAN_FLAGS = new Set([
'json',
'revoke',
'follow',
'strict',
]);
const UI_STRING_FLAGS = new Set([
@@ -893,6 +899,7 @@ async function runPlugin(args) {
case 'whoami': return runPluginWhoami(rest);
case 'export': return runPluginExport(rest);
case 'publish': return runPluginPublish(rest);
case 'yank': return runPluginYank(rest);
default:
console.error(`unknown subcommand: od plugin ${sub}`);
printPluginHelp();
@@ -1229,6 +1236,17 @@ async function spawnPassthrough(command, args) {
});
}
function inferGithubHost(target) {
if (!target || target === 'github.com') return 'github.com';
try {
const parsed = new URL(target);
return parsed.hostname || 'github.com';
} catch {
// Marketplace ids are not URLs; v1 GitHub-backed auth defaults to github.com.
return 'github.com';
}
}
// Phase 4 / spec §14 — `od plugin export <projectId> --as <target>`.
//
// Produces a publish-ready folder from the AppliedPluginSnapshot
@@ -1293,6 +1311,10 @@ async function runMarketplace(args) {
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 plugins <id> [--json] List cached plugin entries for one marketplace.
od marketplace search <query> [--json] Search cached marketplace entries.
od marketplace doctor [id] [--strict] [--json] Validate cached marketplace entries.
od marketplace login <id|url> [--host github.com] Authenticate gh for private GitHub catalogs.
od marketplace refresh <id> Re-fetch the manifest.
od marketplace remove <id> Forget a marketplace.
od marketplace trust <id> [--trust trusted|restricted|official]
@@ -1375,6 +1397,81 @@ Common options:
}
return;
}
case 'plugins': {
const id = rest.find((a) => !a.startsWith('-'));
if (!id) {
console.error('Usage: od marketplace plugins <id> [--json]');
process.exit(2);
}
const resp = await fetch(`${base}/api/marketplaces/${encodeURIComponent(id)}/plugins`);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
console.error(`plugins failed: ${resp.status} ${JSON.stringify(data)}`);
process.exit(1);
}
const plugins = Array.isArray(data?.plugins) ? data.plugins : [];
if (flags.json) {
process.stdout.write(JSON.stringify({ marketplaceId: id, plugins }, null, 2) + '\n');
return;
}
if (plugins.length === 0) {
console.log(`No plugins in marketplace ${id}.`);
return;
}
for (const p of plugins) {
console.log(`${p.name}@${p.version}\t${p.source}\t${p.description ?? ''}`);
}
return;
}
case 'doctor': {
const strict = flags.strict === true;
const id = rest.find((a) => !a.startsWith('-'));
const resp = id
? await fetch(`${base}/api/marketplaces/${encodeURIComponent(id)}`)
: await fetch(`${base}/api/marketplaces`);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
console.error(`doctor failed: ${resp.status} ${JSON.stringify(data)}`);
process.exit(1);
}
const rows = id ? [data] : (data?.marketplaces ?? []);
const { doctorMarketplace } = await import('./plugins/marketplace-doctor.js');
const reports = [];
for (const row of rows) {
reports.push(await doctorMarketplace({
id: row.id,
trust: row.trust,
manifest: row.manifest,
strict,
}));
}
const ok = reports.every((report) => report.ok);
if (flags.json) {
process.stdout.write(JSON.stringify({ ok, reports }, null, 2) + '\n');
} else {
for (const report of reports) {
console.log(`[marketplace doctor] ${report.backendId}: ${report.ok ? 'ok' : 'issues'} (${report.entriesChecked} entries)`);
for (const issue of report.issues) {
console.log(` [${issue.severity}] ${issue.code}${issue.pluginName ? ` ${issue.pluginName}` : ''}: ${issue.message}`);
}
}
}
process.exit(ok ? 0 : 1);
}
case 'login': {
const target = rest.find((a) => !a.startsWith('-'));
const host = typeof flags.host === 'string'
? flags.host
: inferGithubHost(target ?? 'github.com');
const version = await execFileBuffered('gh', ['--version'], { timeout: 10_000 });
if (!version.ok) {
console.error('[marketplace login] GitHub CLI is required. Install gh from https://cli.github.com/ and retry.');
process.exit(1);
}
console.log(`[marketplace login] authenticating gh for ${host}. Tokens stay in gh, not Open Design.`);
const result = await spawnPassthrough('gh', ['auth', 'login', '--hostname', host, '--web']);
process.exit(result.code ?? 0);
}
case 'add': {
const url = rest.find((a) => !a.startsWith('-'));
if (!url) {
@@ -1834,13 +1931,34 @@ function emitPluginList({ entries, json, emptyMessage, showRank }) {
async function runPluginInfo(rest) {
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
const id = rest.find((a) => !a.startsWith('--') && a !== flags['daemon-url'] && a !== flags.source);
const id = rest.find((a) => !a.startsWith('--')
&& a !== flags['daemon-url']
&& a !== flags.source
&& a !== flags.version);
if (!id) {
console.error('Usage: od plugin info <id>');
console.error('Usage: od plugin info <id-or-marketplace-name> [--version <version|tag|range>] [--json]');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`;
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const url = `${base}/api/plugins/${encodeURIComponent(id)}`;
const resp = await fetch(url);
if (resp.ok && !flags.version) {
const data = await resp.json();
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
return;
}
const mpResp = await fetch(`${base}/api/marketplaces`);
if (mpResp.ok) {
const mpData = await mpResp.json().catch(() => ({}));
const resolved = resolveMarketplacePluginFromList(
mpData?.marketplaces ?? [],
flags.version ? `${id}@${flags.version}` : id,
);
if (resolved) {
process.stdout.write(JSON.stringify({ marketplace: resolved }, null, 2) + '\n');
return;
}
}
if (!resp.ok) {
console.error(`GET /api/plugins/${id} failed: ${resp.status} ${await resp.text()}`);
process.exit(1);
@@ -1849,6 +1967,57 @@ async function runPluginInfo(rest) {
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
}
function resolveMarketplacePluginFromList(marketplaces, specifier) {
const parsed = parseCliPluginSpecifier(specifier);
const target = parsed.name.toLowerCase();
for (const marketplace of marketplaces) {
for (const entry of marketplace?.manifest?.plugins ?? []) {
if (String(entry.name ?? '').toLowerCase() !== target) continue;
const version = resolveCliEntryVersion(entry, parsed.range);
if (!version) return null;
return {
marketplaceId: marketplace.id,
marketplaceTrust: marketplace.trust,
name: entry.name,
version: version.version,
source: version.source,
ref: version.ref,
integrity: version.integrity,
manifestDigest: version.manifestDigest,
entry,
};
}
}
return null;
}
function parseCliPluginSpecifier(input) {
const trimmed = String(input ?? '').trim();
const slash = trimmed.indexOf('/');
const at = trimmed.lastIndexOf('@');
if (slash > 0 && at > slash + 1) {
return { name: trimmed.slice(0, at), range: trimmed.slice(at + 1) };
}
return { name: trimmed, range: undefined };
}
function resolveCliEntryVersion(entry, range) {
if (entry?.yanked) return null;
const versions = Array.isArray(entry?.versions) ? entry.versions : [];
const target = range && range !== 'latest'
? (entry?.distTags?.[range] ?? range)
: (entry?.distTags?.latest ?? entry?.version);
const version = versions.find((item) => item.version === target) ?? null;
if (version?.yanked) return null;
return {
version: target,
source: version?.source ?? entry?.source,
ref: version?.ref ?? entry?.ref,
integrity: version?.integrity ?? version?.dist?.integrity ?? entry?.integrity ?? entry?.dist?.integrity,
manifestDigest: version?.manifestDigest ?? version?.dist?.manifestDigest ?? entry?.manifestDigest ?? entry?.dist?.manifestDigest,
};
}
// Plan §3.MM1 — `od plugin manifest <id>`. Prints just the parsed
// manifest JSON, no wrapper. Useful for plugin authors who want to
// compare the daemon's view to their on-disk open-design.json
@@ -1932,7 +2101,7 @@ async function runPluginInstall(rest) {
' od plugin install ./local-folder\n' +
' od plugin install github:owner/repo[@ref][/subpath]\n' +
' od plugin install https://example.com/plugin.tar.gz\n' +
' od plugin install <name> # resolves through configured marketplaces');
' od plugin install <name>[@version|tag|range] # resolves through configured marketplaces');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/install`;
@@ -1949,6 +2118,8 @@ async function runPluginInstall(rest) {
const decoder = new TextDecoder();
let buffer = '';
let exitCode = 0;
const events = [];
let finalEvent = null;
while (true) {
const { value, done } = await reader.read();
if (done) break;
@@ -1961,19 +2132,29 @@ async function runPluginInstall(rest) {
const dataLine = lines.find((l) => l.startsWith('data: '));
const event = eventLine ? eventLine.slice('event: '.length) : 'message';
const data = dataLine ? safeParseJson(dataLine.slice('data: '.length)) : null;
events.push({ event, data });
if (event === 'progress') {
console.log(`[install] ${data?.phase ?? '...'}: ${data?.message ?? ''}`);
if (!flags.json) console.log(`[install] ${data?.phase ?? '...'}: ${data?.message ?? ''}`);
} else if (event === 'success') {
console.log(`[install] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`);
if (Array.isArray(data?.warnings) && data.warnings.length > 0) {
finalEvent = data;
if (!flags.json) console.log(`[install] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`);
if (!flags.json && Array.isArray(data?.warnings) && data.warnings.length > 0) {
for (const w of data.warnings) console.log(`[install] warn: ${w}`);
}
} else if (event === 'error') {
console.error(`[install] error: ${data?.message ?? 'unknown'}`);
finalEvent = data;
if (!flags.json) console.error(`[install] error: ${data?.message ?? 'unknown'}`);
exitCode = 1;
}
}
}
if (flags.json) {
process.stdout.write(JSON.stringify({
ok: exitCode === 0,
result: finalEvent,
events,
}, null, 2) + '\n');
}
process.exit(exitCode);
}
@@ -2606,13 +2787,16 @@ async function runPluginUpgrade(rest) {
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
const id = rest.find((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.source);
if (!id) {
console.error('Usage: od plugin upgrade <id>');
console.error('Usage: od plugin upgrade <id> [--policy latest|pinned] [--json]');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/upgrade`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
body: JSON.stringify({
policy: flags.policy === 'pinned' ? 'pinned' : 'latest',
}),
});
if (!resp.ok || !resp.body) {
let msg = '';
@@ -2624,6 +2808,8 @@ async function runPluginUpgrade(rest) {
const decoder = new TextDecoder();
let buffer = '';
let exitCode = 0;
const events = [];
let finalEvent = null;
while (true) {
const { value, done } = await reader.read();
if (done) break;
@@ -2636,19 +2822,30 @@ async function runPluginUpgrade(rest) {
const dataLine = lines.find((l) => l.startsWith('data: '));
const event = eventLine ? eventLine.slice('event: '.length) : 'message';
const data = dataLine ? safeParseJson(dataLine.slice('data: '.length)) : null;
events.push({ event, data });
if (event === 'progress') {
console.log(`[upgrade] ${data?.phase ?? '...'}: ${data?.message ?? ''}`);
if (!flags.json) console.log(`[upgrade] ${data?.phase ?? '...'}: ${data?.message ?? ''}`);
} else if (event === 'success') {
console.log(`[upgrade] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`);
if (Array.isArray(data?.warnings) && data.warnings.length > 0) {
finalEvent = data;
if (!flags.json) console.log(`[upgrade] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`);
if (!flags.json && Array.isArray(data?.warnings) && data.warnings.length > 0) {
for (const w of data.warnings) console.log(`[upgrade] warn: ${w}`);
}
} else if (event === 'error') {
console.error(`[upgrade] error: ${data?.message ?? 'unknown'}`);
finalEvent = data;
if (!flags.json) console.error(`[upgrade] error: ${data?.message ?? 'unknown'}`);
exitCode = 1;
}
}
}
if (flags.json) {
process.stdout.write(JSON.stringify({
ok: exitCode === 0,
policy: flags.policy === 'pinned' ? 'pinned' : 'latest',
result: finalEvent,
events,
}, null, 2) + '\n');
}
process.exit(exitCode);
}
@@ -2764,13 +2961,14 @@ function coerceCliValue(raw) {
// always under the author's control.
async function runPluginPublish(rest) {
const flags = parseFlags(rest, {
string: new Set(['daemon-url', 'to', 'snapshot-id', 'repo']),
string: new Set(['daemon-url', 'to', 'snapshot-id', 'repo', 'catalog']),
boolean: new Set(['help', 'h', 'json', 'open']),
});
if (rest.length === 0 || flags.help || flags.h) {
console.log(`Usage:
od plugin publish <pluginId> --to open-design|anthropics-skills|awesome-agent-skills|clawhub|skills-sh
[--repo <github-url>] [--snapshot-id <id>] [--open] [--json]
od plugin publish <pluginId> --to marketplace-json --catalog ./open-design-marketplace.json --repo <github-url>
The CLI prints the catalog's submission URL + a pre-filled PR body.
Pass --open to auto-launch the system browser. Use --snapshot-id to
@@ -2814,6 +3012,27 @@ publish from a frozen run snapshot rather than the live installed copy.`);
if (typeof flags.repo === 'string' && flags.repo.length > 0) {
meta.repoUrl = flags.repo;
}
if (target === 'marketplace-json') {
if (typeof flags.catalog !== 'string' || flags.catalog.length === 0) {
console.error('--catalog <path> is required for --to marketplace-json');
process.exit(2);
}
if (!meta.repoUrl) {
console.error('--repo <github-url> is required for --to marketplace-json so the source can be reproduced');
process.exit(2);
}
const outcome = await publishToMarketplaceJson({
catalogPath: flags.catalog,
meta,
});
if (flags.json) {
process.stdout.write(JSON.stringify(outcome, null, 2) + '\n');
} else {
console.log(`[publish] updated ${outcome.catalogPath}`);
console.log(`[publish] ${outcome.entry.name}@${outcome.entry.version} -> ${outcome.entry.source}`);
}
return;
}
const { buildPublishLink, PublishError } = await import('./plugins/publish.js');
let link;
try {
@@ -2842,6 +3061,118 @@ publish from a frozen run snapshot rather than the live installed copy.`);
}
}
async function publishToMarketplaceJson({ catalogPath, meta }) {
const [{ dirname, resolve }, { mkdir, readFile, writeFile }, { PublishError, upsertMarketplaceJsonEntry }] = await Promise.all([
import('node:path'),
import('node:fs/promises'),
import('./plugins/publish.js'),
]);
const resolvedPath = resolve(process.cwd(), catalogPath);
let existing = null;
try {
existing = JSON.parse(await readFile(resolvedPath, 'utf8'));
} catch (err) {
if (err?.code !== 'ENOENT') {
throw err;
}
}
let outcome;
try {
outcome = upsertMarketplaceJsonEntry({ manifest: existing, meta });
} catch (err) {
if (err instanceof PublishError) {
console.error(`[publish] ${err.message}`);
process.exit(2);
}
throw err;
}
await mkdir(dirname(resolvedPath), { recursive: true });
await writeFile(resolvedPath, `${JSON.stringify(outcome.manifest, null, 2)}\n`, 'utf8');
return {
catalogPath: resolvedPath,
inserted: outcome.inserted,
entry: outcome.entry,
manifest: {
name: outcome.manifest.name,
version: outcome.manifest.version,
plugins: outcome.manifest.plugins.length,
},
};
}
async function runPluginYank(rest) {
const flags = parseFlags(rest, {
string: new Set(['daemon-url', 'reason', 'to']),
boolean: new Set(['help', 'h', 'json', 'open']),
});
if (rest.length === 0 || flags.help || flags.h) {
console.log(`Usage:
od plugin yank <vendor/plugin-name>@<version> --reason "<why>" [--to open-design] [--json]
Yanking never deletes metadata or bytes. It opens the registry review flow that
marks a version unresolvable for new installs while preserving lockfile replay.`);
process.exit(rest.length === 0 ? 2 : 0);
}
const spec = rest.find((a) => !a.startsWith('-') && a !== flags.reason && a !== flags.to);
const reason = typeof flags.reason === 'string' ? flags.reason.trim() : '';
const parsed = parseCliPluginSpecifier(spec);
if (!parsed.name || !parsed.range) {
console.error('Usage: od plugin yank <vendor/plugin-name>@<version> --reason "<why>"');
process.exit(2);
}
if (!reason) {
console.error('--reason is required for yanking');
process.exit(2);
}
const target = flags.to ?? 'open-design';
if (target !== 'open-design') {
console.error('Only --to open-design is supported in this v1 GitHub-backed yank flow.');
process.exit(2);
}
const title = `Yank ${parsed.name}@${parsed.range}`;
const body = [
`## Yank ${parsed.name}@${parsed.range}`,
'',
`Reason: ${reason}`,
'',
'Expected registry patch:',
'',
'```json',
JSON.stringify({
name: parsed.name,
version: parsed.range,
yanked: true,
yankReason: reason,
}, null, 2),
'```',
'',
'Generated by `od plugin yank`.',
].join('\n');
const params = new URLSearchParams({ title, body });
const payload = {
catalog: 'open-design',
name: parsed.name,
version: parsed.range,
reason,
url: `https://github.com/open-design/plugin-registry/issues/new?${params.toString()}`,
body,
};
if (flags.json) {
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
} else {
console.log(`[yank] ${payload.url}`);
console.log('---');
console.log(body);
}
if (flags.open) {
const opener = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'start'
: 'xdg-open';
const { spawn } = await import('node:child_process');
spawn(opener, [payload.url], { detached: true, stdio: 'ignore' }).unref();
}
}
async function runPluginDoctor(rest) {
// Plan §3.HH1 — --strict promotes warnings to errors so CI can
// opt into 'no warnings allowed' mode without parsing the issue

View File

@@ -88,6 +88,7 @@ export * from './connector-gate.js';
export * from './export.js';
export * from './doctor.js';
export * from './installer.js';
export * from './lockfile.js';
export * from './persistence.js';
export * from './marketplaces.js';
export * from './pipeline.js';

View File

@@ -16,8 +16,9 @@
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { createHash } from 'node:crypto';
import { promises as fsp } from 'node:fs';
import { Readable } from 'node:stream';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { x as tarExtract } from 'tar';
import {
@@ -36,6 +37,7 @@ import type {
} from '@open-design/contracts';
import type Database from 'better-sqlite3';
import { recordPluginEvent } from './events.js';
import { upsertPluginLockfileEntry } from './lockfile.js';
type SqliteDb = Database.Database;
@@ -85,6 +87,9 @@ export interface InstallOptions {
resolvedRef?: string;
manifestDigest?: string;
archiveIntegrity?: string;
// Optional runtime-data lockfile path. Daemon routes pass
// `<OD_DATA_DIR>/od-plugin-lock.json`; tests can point at temp dirs.
lockfilePath?: string;
}
export type ArchiveFetcher = (url: string) => Promise<{
@@ -181,6 +186,26 @@ async function* installFromArchiveUrl(
};
return;
}
const archivePath = path.join(tmpRoot, 'archive.tgz');
let computedIntegrity: string;
try {
computedIntegrity = await writeArchiveAndDigest(resp.body, archivePath, maxBytes);
} catch (err) {
yield {
kind: 'error',
message: `Archive download failed: ${(err as Error).message}`,
warnings: [],
};
return;
}
if (opts.archiveIntegrity && !integrityMatches(opts.archiveIntegrity, computedIntegrity)) {
yield {
kind: 'error',
message: `Archive integrity mismatch: expected ${opts.archiveIntegrity}, got ${computedIntegrity}`,
warnings: [],
};
return;
}
yield { kind: 'progress', phase: 'copying', message: 'Extracting archive' };
let symlinkSeen = false;
let traversalSeen = false;
@@ -192,7 +217,7 @@ async function* installFromArchiveUrl(
// any path-traversal segment; we then surface those as a clean
// install error instead of silently skipping unsafe entries.
await pipeline(
resp.body as NodeJS.ReadableStream,
fs.createReadStream(archivePath),
tarExtract({
cwd: tmpRoot,
strip: 1,
@@ -261,6 +286,7 @@ async function* installFromArchiveUrl(
// provenance accurately.
yield* installFromLocalFolder(db, {
...opts,
archiveIntegrity: opts.archiveIntegrity ?? computedIntegrity,
source: opts.source,
// Drive the local backend through the staged folder; the
// override on `_stagedFolder` is internal and lets us re-use the
@@ -283,6 +309,40 @@ async function defaultFetcher(url: string): ReturnType<ArchiveFetcher> {
};
}
async function writeArchiveAndDigest(
body: Readable,
archivePath: string,
maxBytes: number,
): Promise<string> {
const hash = createHash('sha256');
let bytes = 0;
const digestStream = new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytes += chunk.length;
if (bytes > maxBytes) {
callback(new Error(`Downloaded archive exceeds ${maxBytes} bytes`));
return;
}
hash.update(chunk);
callback(null, chunk);
},
});
await pipeline(body as NodeJS.ReadableStream, digestStream, fs.createWriteStream(archivePath));
return `sha256:${hash.digest('hex')}`;
}
function integrityMatches(expected: string, computed: string): boolean {
const normalizedExpected = expected.trim();
const normalizedComputed = computed.trim();
if (normalizedExpected === normalizedComputed) return true;
if (normalizedExpected.startsWith('sha256-')) {
const hex = normalizedComputed.replace(/^sha256:/, '');
const base64 = Buffer.from(hex, 'hex').toString('base64');
return normalizedExpected === `sha256-${base64}`;
}
return false;
}
async function measureTreeSize(root: string): Promise<number> {
let total = 0;
const queue: string[] = [root];
@@ -397,6 +457,9 @@ export async function* installFromLocalFolder(
yield { kind: 'progress', phase: 'persisting', message: 'Writing installed_plugins row' };
upsertInstalledPlugin(db, parsed.record);
if (opts.lockfilePath) {
await upsertPluginLockfileEntry(opts.lockfilePath, parsed.record);
}
// Plan §3.II1 / §3.JJ1 — emit 'plugin.installed' OR
// 'plugin.upgraded' (per opts.eventKind) so ops dashboards +

View File

@@ -0,0 +1,87 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { InstalledPluginRecord } from '@open-design/contracts';
export interface PluginLockEntry {
name: string;
version: string;
source: string;
sourceKind: string;
sourceMarketplaceId?: string;
sourceMarketplaceEntryName?: string;
resolvedSource?: string;
resolvedRef?: string;
manifestDigest?: string;
archiveIntegrity?: string;
lockedAt: number;
}
export interface PluginLockfile {
schemaVersion: 1;
plugins: Record<string, PluginLockEntry>;
}
export function defaultPluginLockfile(): PluginLockfile {
return { schemaVersion: 1, plugins: {} };
}
export function lockEntryFromInstalled(
plugin: InstalledPluginRecord,
lockedAt = Date.now(),
): PluginLockEntry {
const entry: PluginLockEntry = {
name: plugin.sourceMarketplaceEntryName ?? plugin.id,
version: plugin.sourceMarketplaceEntryVersion ?? plugin.version,
source: plugin.source,
sourceKind: plugin.sourceKind,
lockedAt,
};
if (plugin.sourceMarketplaceId) entry.sourceMarketplaceId = plugin.sourceMarketplaceId;
if (plugin.sourceMarketplaceEntryName) entry.sourceMarketplaceEntryName = plugin.sourceMarketplaceEntryName;
if (plugin.resolvedSource) entry.resolvedSource = plugin.resolvedSource;
if (plugin.resolvedRef) entry.resolvedRef = plugin.resolvedRef;
if (plugin.manifestDigest) entry.manifestDigest = plugin.manifestDigest;
if (plugin.archiveIntegrity) entry.archiveIntegrity = plugin.archiveIntegrity;
return entry;
}
export async function readPluginLockfile(filePath: string): Promise<PluginLockfile> {
try {
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as PluginLockfile;
return {
schemaVersion: 1,
plugins: parsed && typeof parsed.plugins === 'object' && parsed.plugins
? parsed.plugins
: {},
};
} catch {
return defaultPluginLockfile();
}
}
export async function writePluginLockfile(
filePath: string,
lockfile: PluginLockfile,
): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
const sorted: PluginLockfile = {
schemaVersion: 1,
plugins: Object.fromEntries(
Object.entries(lockfile.plugins).sort(([left], [right]) => left.localeCompare(right)),
),
};
await writeFile(filePath, JSON.stringify(sorted, null, 2) + '\n', 'utf8');
}
export async function upsertPluginLockfileEntry(
filePath: string,
plugin: InstalledPluginRecord,
lockedAt = Date.now(),
): Promise<PluginLockfile> {
const lockfile = await readPluginLockfile(filePath);
const entry = lockEntryFromInstalled(plugin, lockedAt);
lockfile.plugins[entry.name] = entry;
await writePluginLockfile(filePath, lockfile);
return lockfile;
}

View File

@@ -0,0 +1,81 @@
import type { MarketplaceManifest } from '@open-design/contracts';
import type { RegistryDoctorIssue, RegistryDoctorReport } from '@open-design/registry-protocol';
import { StaticRegistryBackend } from '../registry/static-backend.js';
export interface MarketplaceDoctorInput {
id: string;
trust: 'official' | 'trusted' | 'restricted';
manifest: MarketplaceManifest;
checkedAt?: number;
strict?: boolean;
}
export async function doctorMarketplace(
input: MarketplaceDoctorInput,
): Promise<RegistryDoctorReport & { warningsAsErrors: boolean }> {
const backend = new StaticRegistryBackend({
id: input.id,
trust: input.trust,
manifest: input.manifest,
});
const base = await backend.doctor();
const issues: RegistryDoctorIssue[] = [...base.issues];
const names = new Set<string>();
for (const entry of input.manifest.plugins ?? []) {
const lower = entry.name.toLowerCase();
if (names.has(lower)) {
issues.push({
severity: 'error',
code: 'duplicate-name',
message: 'Registry entries must have stable unique plugin ids.',
pluginName: entry.name,
});
}
names.add(lower);
if (entry.dist?.archive && !entry.dist.integrity && !entry.integrity) {
issues.push({
severity: 'error',
code: 'archive-integrity-required',
message: 'Archive distribution entries must include sha256 integrity.',
pluginName: entry.name,
});
}
if (entry.distTags?.latest) {
const hasLatest = (entry.versions ?? []).some((version) =>
version.version === entry.distTags?.latest && !version.yanked,
) || entry.version === entry.distTags.latest;
if (!hasLatest) {
issues.push({
severity: 'error',
code: 'bad-latest-tag',
message: 'distTags.latest must point at a non-yanked version.',
pluginName: entry.name,
});
}
}
const publisherId = entry.publisher?.id ?? entry.publisher?.github;
if (!publisherId) {
issues.push({
severity: 'warning',
code: 'missing-publisher',
message: 'Registry entry should declare publisher identity.',
pluginName: entry.name,
});
}
}
const strict = input.strict === true;
return {
ok: !issues.some((issue) => issue.severity === 'error') &&
(!strict || !issues.some((issue) => issue.severity === 'warning')),
backendId: base.backendId,
checkedAt: input.checkedAt ?? base.checkedAt,
entriesChecked: base.entriesChecked,
issues,
warningsAsErrors: strict,
};
}

View File

@@ -23,6 +23,10 @@ import {
OPEN_DESIGN_PLUGIN_SPEC_VERSION,
type MarketplaceManifest,
} from '@open-design/contracts';
import {
parsePluginSpecifier,
resolveMarketplaceEntryVersion,
} from '../registry/versioning.js';
type SqliteDb = Database.Database;
@@ -401,12 +405,15 @@ export function resolvePluginInMarketplaces(
pluginName: string,
): ResolvedPluginEntry | null {
const rows = listMarketplaces(db);
const target = pluginName.trim().toLowerCase();
const specifier = parsePluginSpecifier(pluginName);
const target = specifier.name.trim().toLowerCase();
if (!target) return null;
for (const row of rows) {
const entries = row.manifest.plugins ?? [];
for (const entry of entries) {
if (entry.name && entry.name.toLowerCase() === target) {
const resolvedVersion = resolveMarketplaceEntryVersion(entry, specifier.range);
if (!resolvedVersion) continue;
const result: ResolvedPluginEntry = {
marketplaceId: row.id,
marketplaceUrl: row.url,
@@ -414,24 +421,12 @@ export function resolvePluginInMarketplaces(
marketplaceSpecVersion: row.specVersion,
marketplaceVersion: row.version,
pluginName: entry.name,
pluginVersion: entry.version,
source: entry.source,
pluginVersion: resolvedVersion.version,
source: resolvedVersion.source,
};
if (entry.ref) result.ref = entry.ref;
const manifestDigest =
typeof entry.manifestDigest === 'string'
? entry.manifestDigest
: typeof entry.dist?.manifestDigest === 'string'
? entry.dist.manifestDigest
: undefined;
const archiveIntegrity =
typeof entry.integrity === 'string'
? entry.integrity
: typeof entry.dist?.integrity === 'string'
? entry.dist.integrity
: undefined;
if (manifestDigest) result.manifestDigest = manifestDigest;
if (archiveIntegrity) result.archiveIntegrity = archiveIntegrity;
if (resolvedVersion.ref) result.ref = resolvedVersion.ref;
if (resolvedVersion.manifestDigest) result.manifestDigest = resolvedVersion.manifestDigest;
if (resolvedVersion.archiveIntegrity) result.archiveIntegrity = resolvedVersion.archiveIntegrity;
if (entry.description) result.description = entry.description;
return result;
}

View File

@@ -47,6 +47,37 @@ export interface PublishLink {
prBody: string;
}
export interface MarketplaceJsonManifest {
specVersion: string;
name: string;
version: string;
generatedAt?: string;
plugins: MarketplaceJsonEntry[];
[key: string]: unknown;
}
export interface MarketplaceJsonEntry {
name: string;
source: string;
version: string;
title?: string;
description?: string;
publisher?: {
name?: string;
github?: string;
url?: string;
};
homepage?: string;
repo?: string;
[key: string]: unknown;
}
export interface MarketplaceJsonPublishOutcome {
manifest: MarketplaceJsonManifest;
entry: MarketplaceJsonEntry;
inserted: boolean;
}
export class PublishError extends Error {
constructor(message: string) {
super(message);
@@ -129,6 +160,65 @@ export function buildPublishLink(args: {
throw new PublishError(`unhandled catalog: ${String(args.catalog)}`);
}
export function buildMarketplaceJsonEntry(meta: PublishMetadata): MarketplaceJsonEntry {
if (!meta.pluginId.includes('/')) {
throw new PublishError('marketplace-json publish requires a stable namespaced id: vendor/plugin-name');
}
if (!meta.repoUrl) {
throw new PublishError('marketplace-json publish requires meta.repoUrl');
}
const parsedRepo = parseGithubRepo(meta.repoUrl);
const entry: MarketplaceJsonEntry = {
name: meta.pluginId,
source: parsedRepo.source,
version: meta.pluginVersion,
repo: meta.repoUrl,
homepage: meta.repoUrl,
publisher: {
name: parsedRepo.owner,
github: parsedRepo.owner,
url: `https://github.com/${parsedRepo.owner}`,
},
};
if (meta.pluginTitle) entry.title = meta.pluginTitle;
if (meta.pluginDescription) entry.description = meta.pluginDescription;
return entry;
}
export function upsertMarketplaceJsonEntry(args: {
manifest?: Partial<MarketplaceJsonManifest> | null;
meta: PublishMetadata;
generatedAt?: string;
}): MarketplaceJsonPublishOutcome {
const entry = buildMarketplaceJsonEntry(args.meta);
const existing = args.manifest ?? {};
const plugins = Array.isArray(existing.plugins) ? existing.plugins : [];
let inserted = true;
const nextPlugins = plugins.map((plugin) => {
if (plugin?.name === entry.name) {
inserted = false;
return {
...plugin,
...entry,
};
}
return plugin;
});
if (inserted) {
nextPlugins.push(entry);
}
nextPlugins.sort((a, b) => String(a.name).localeCompare(String(b.name)));
const manifest: MarketplaceJsonManifest = {
...existing,
specVersion: typeof existing.specVersion === 'string' ? existing.specVersion : '1.0.0',
name: typeof existing.name === 'string' ? existing.name : 'open-design-marketplace',
version: typeof existing.version === 'string' ? existing.version : '1.0.0',
generatedAt: args.generatedAt ?? new Date().toISOString(),
plugins: nextPlugins,
};
return { manifest, entry, inserted };
}
function newIssueUrl(repo: string, title: string, body: string): string {
const params = new URLSearchParams();
params.set('title', title);
@@ -136,6 +226,38 @@ function newIssueUrl(repo: string, title: string, body: string): string {
return `https://github.com/${repo}/issues/new?${params.toString()}`;
}
function parseGithubRepo(repoUrl: string): { owner: string; repo: string; source: string } {
let url: URL;
try {
url = new URL(repoUrl);
} catch {
throw new PublishError(`unsupported repo URL: ${repoUrl}`);
}
if (url.hostname.toLowerCase() !== 'github.com') {
throw new PublishError('marketplace-json publish currently requires a github.com repo URL');
}
const parts = url.pathname.split('/').filter(Boolean);
const owner = parts[0];
const repo = parts[1]?.replace(/\.git$/i, '');
if (!owner || !repo) {
throw new PublishError(`unsupported GitHub repo URL: ${repoUrl}`);
}
if (parts[2] === 'tree' && parts[3]) {
const ref = parts[3];
const subpath = parts.slice(4).join('/');
return {
owner,
repo,
source: `github:${owner}/${repo}@${ref}${subpath ? `/${subpath}` : ''}`,
};
}
return {
owner,
repo,
source: `github:${owner}/${repo}`,
};
}
function renderPrBody(m: PublishMetadata): string {
const lines: string[] = [];
lines.push(`## ${m.pluginTitle ?? m.pluginId}`);

View File

@@ -0,0 +1,127 @@
import type Database from 'better-sqlite3';
import type { MarketplaceManifest, MarketplacePluginEntry } from '@open-design/contracts';
import type {
RegistryEntry,
RegistryPublishOutcome,
RegistryPublishRequest,
RegistryTrust,
RegistryYankOutcome,
} from '@open-design/registry-protocol';
import { StaticRegistryBackend, toRegistryEntry } from './static-backend.js';
type SqliteDb = Database.Database;
export interface DatabaseRegistryBackendOptions {
id: string;
trust?: RegistryTrust;
db: SqliteDb;
}
export class DatabaseRegistryBackend extends StaticRegistryBackend {
readonly db: SqliteDb;
constructor(options: DatabaseRegistryBackendOptions) {
ensureRegistryTables(options.db);
super({
id: options.id,
kind: 'db',
trust: options.trust ?? 'restricted',
manifest: manifestFromDb(options.db, options.id),
});
this.db = options.db;
}
async publish(request: RegistryPublishRequest): Promise<RegistryPublishOutcome> {
upsertRegistryEntry(this.db, this.id, request.entry);
const [vendor, name] = request.entry.name.split('/');
return {
ok: true,
dryRun: false,
changedFiles: [
`db://${this.id}/plugins/${vendor}/${name}`,
`db://${this.id}/plugins/${vendor}/${name}/versions/${request.entry.version}`,
],
warnings: [],
};
}
async yank(name: string, version: string, reason: string): Promise<RegistryYankOutcome> {
const row = this.db.prepare(`
SELECT entry_json FROM registry_entries WHERE backend_id = ? AND name = ?
`).get(this.id, name) as { entry_json: string } | undefined;
if (!row) {
return { ok: false, name, version, reason, warnings: [`${name} not found`] };
}
const entry = JSON.parse(row.entry_json) as RegistryEntry;
const versions = entry.versions ?? [{ version: entry.version, source: entry.source }];
const nextVersions = versions.map((item) => item.version === version
? { ...item, yanked: true, yankedAt: new Date().toISOString(), yankReason: reason }
: item);
upsertRegistryEntry(this.db, this.id, {
...entry,
versions: nextVersions,
...(entry.version === version
? { yanked: true, yankedAt: new Date().toISOString(), yankReason: reason }
: {}),
});
return { ok: true, name, version, reason, warnings: [] };
}
protected override getManifest(): MarketplaceManifest {
return manifestFromDb(this.db, this.id);
}
}
export function ensureRegistryTables(db: SqliteDb): void {
db.exec(`
CREATE TABLE IF NOT EXISTS registry_entries (
backend_id TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
entry_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (backend_id, name)
)
`);
}
export function upsertRegistryEntry(
db: SqliteDb,
backendId: string,
entry: RegistryEntry,
now = Date.now(),
): void {
db.prepare(`
INSERT INTO registry_entries (backend_id, name, version, entry_json, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(backend_id, name) DO UPDATE SET
version = excluded.version,
entry_json = excluded.entry_json,
updated_at = excluded.updated_at
`).run(backendId, entry.name, entry.version, JSON.stringify(entry), now);
}
function manifestFromDb(db: SqliteDb, backendId: string): MarketplaceManifest {
const rows = db.prepare(`
SELECT entry_json FROM registry_entries WHERE backend_id = ? ORDER BY name ASC
`).all(backendId) as Array<{ entry_json: string }>;
const plugins: MarketplacePluginEntry[] = [];
for (const row of rows) {
const entry = JSON.parse(row.entry_json) as RegistryEntry;
const marketplaceEntry: MarketplacePluginEntry = {
...entry,
name: entry.name,
source: entry.source,
version: entry.version,
};
if (toRegistryEntry(marketplaceEntry)) {
plugins.push(marketplaceEntry);
}
}
return {
specVersion: '1.0.0',
name: backendId,
version: '0.0.0',
plugins,
};
}

View File

@@ -0,0 +1,166 @@
import type { MarketplaceManifest } from '@open-design/contracts';
import type {
RegistryPublishOutcome,
RegistryPublishRequest,
RegistryYankOutcome,
} from '@open-design/registry-protocol';
import { StaticRegistryBackend } from './static-backend.js';
export interface GithubRegistryClient {
readMarketplace(owner: string, repo: string, ref: string, path: string): Promise<MarketplaceManifest>;
createPublishPullRequest?(request: GithubPublishMutation): Promise<{ url: string }>;
}
export interface GithubPublishMutation {
owner: string;
repo: string;
baseRef: string;
branchName: string;
title: string;
body: string;
files: Array<{ path: string; content: string }>;
}
export interface GithubRegistryBackendOptions {
id: string;
owner: string;
repo: string;
ref?: string;
marketplacePath?: string;
client: GithubRegistryClient;
}
export class GithubRegistryBackend extends StaticRegistryBackend {
readonly owner: string;
readonly repo: string;
readonly ref: string;
readonly marketplacePath: string;
readonly client: GithubRegistryClient;
private constructor(options: GithubRegistryBackendOptions & { manifest: MarketplaceManifest }) {
super({
id: options.id,
kind: 'github',
trust: 'official',
manifest: options.manifest,
});
this.owner = options.owner;
this.repo = options.repo;
this.ref = options.ref ?? 'main';
this.marketplacePath = options.marketplacePath ?? 'plugins/registry/official/open-design-marketplace.json';
this.client = options.client;
}
static async create(options: GithubRegistryBackendOptions): Promise<GithubRegistryBackend> {
const ref = options.ref ?? 'main';
const marketplacePath = options.marketplacePath ?? 'plugins/registry/official/open-design-marketplace.json';
const manifest = await options.client.readMarketplace(
options.owner,
options.repo,
ref,
marketplacePath,
);
return new GithubRegistryBackend({ ...options, ref, marketplacePath, manifest });
}
async publish(request: RegistryPublishRequest): Promise<RegistryPublishOutcome> {
const [vendor, name] = request.entry.name.split('/');
const version = request.entry.version;
const root = `plugins/${vendor}/${name}`;
const files = [
{
path: `${root}/entry.json`,
content: JSON.stringify(request.entry, null, 2) + '\n',
},
{
path: `${root}/versions/${version}.json`,
content: JSON.stringify({
...request.entry,
publishedAt: new Date().toISOString(),
tag: request.tag ?? 'latest',
}, null, 2) + '\n',
},
];
if (request.dryRun || !this.client.createPublishPullRequest) {
return {
ok: true,
dryRun: true,
changedFiles: files.map((file) => file.path),
warnings: this.client.createPublishPullRequest
? []
: ['github mutation client unavailable; emitted dry-run payload only'],
};
}
const mutation: GithubPublishMutation = {
owner: this.owner,
repo: this.repo,
baseRef: this.ref,
branchName: `publish/${vendor}-${name}-${version}`,
title: `Add ${request.entry.name}@${version}`,
body: renderPublishBody(request),
files,
};
const pr = await this.client.createPublishPullRequest(mutation);
return {
ok: true,
dryRun: false,
pullRequestUrl: pr.url,
changedFiles: files.map((file) => file.path),
warnings: [],
};
}
async yank(name: string, version: string, reason: string): Promise<RegistryYankOutcome> {
const [vendor, pluginName] = name.split('/');
const path = `plugins/${vendor}/${pluginName}/versions/${version}.json`;
if (!this.client.createPublishPullRequest) {
return {
ok: true,
name,
version,
reason,
warnings: ['github mutation client unavailable; emitted dry-run yank only'],
};
}
const mutation: GithubPublishMutation = {
owner: this.owner,
repo: this.repo,
baseRef: this.ref,
branchName: `yank/${vendor}-${pluginName}-${version}`,
title: `Yank ${name}@${version}`,
body: `Yank ${name}@${version}\n\nReason: ${reason}\n`,
files: [
{
path,
content: JSON.stringify({
name,
version,
yanked: true,
yankedAt: new Date().toISOString(),
yankReason: reason,
}, null, 2) + '\n',
},
],
};
const pr = await this.client.createPublishPullRequest(mutation);
return { ok: true, name, version, reason, pullRequestUrl: pr.url, warnings: [] };
}
}
function renderPublishBody(request: RegistryPublishRequest): string {
return [
`Publish ${request.entry.name}@${request.entry.version}`,
'',
request.entry.description ?? '',
'',
'## Registry metadata',
'',
`- source: ${request.entry.source}`,
`- integrity: ${request.entry.integrity ?? request.entry.dist?.integrity ?? '(pending)'}`,
`- manifestDigest: ${request.entry.manifestDigest ?? request.entry.dist?.manifestDigest ?? '(pending)'}`,
`- capabilities: ${(request.entry.capabilitiesSummary ?? []).join(', ') || '(none declared)'}`,
request.changelog ? `\n## Changelog\n\n${request.changelog}` : '',
].filter(Boolean).join('\n');
}

View File

@@ -0,0 +1,191 @@
import type { MarketplaceManifest, MarketplacePluginEntry } from '@open-design/contracts';
import type {
RegistryBackend,
RegistryDoctorReport,
RegistryEntry,
RegistrySearchQuery,
RegistrySearchResult,
RegistryTrust,
ResolvedRegistryEntry,
} from '@open-design/registry-protocol';
import {
RegistryEntrySchema,
RegistrySearchQuerySchema,
} from '@open-design/registry-protocol';
import {
parsePluginSpecifier,
resolveMarketplaceEntryVersion,
} from './versioning.js';
export interface StaticRegistryBackendOptions {
id: string;
kind?: 'github' | 'http' | 'local' | 'db';
trust: RegistryTrust;
manifest: MarketplaceManifest;
}
export class StaticRegistryBackend implements RegistryBackend {
readonly id: string;
readonly kind: 'github' | 'http' | 'local' | 'db';
readonly trust: RegistryTrust;
protected readonly manifestData: MarketplaceManifest;
constructor(options: StaticRegistryBackendOptions) {
this.id = options.id;
this.kind = options.kind ?? 'http';
this.trust = options.trust;
this.manifestData = options.manifest;
}
async list(): Promise<RegistryEntry[]> {
return (this.getManifest().plugins ?? [])
.filter((entry) => !entry.yanked)
.flatMap((entry) => {
const parsed = toRegistryEntry(entry);
return parsed ? [parsed] : [];
});
}
async search(input: RegistrySearchQuery): Promise<RegistrySearchResult[]> {
const query = RegistrySearchQuerySchema.parse(input);
const terms = query.query.toLowerCase().split(/\s+/g).filter(Boolean);
const tags = new Set((query.tags ?? []).map((tag) => tag.toLowerCase()));
const entries = await this.list();
const results: RegistrySearchResult[] = [];
for (const entry of entries) {
if (tags.size > 0) {
const entryTags = new Set((entry.tags ?? []).map((tag) => tag.toLowerCase()));
if (![...tags].every((tag) => entryTags.has(tag))) continue;
}
const haystack = [
entry.name,
entry.title ?? '',
entry.description ?? '',
...(entry.tags ?? []),
...(entry.capabilitiesSummary ?? []),
entry.publisher?.id ?? '',
entry.publisher?.github ?? '',
].join(' ').toLowerCase();
const matched = terms.filter((term) => haystack.includes(term));
if (terms.length > 0 && matched.length === 0) continue;
results.push({
entry,
score: terms.length === 0 ? 0 : matched.length / terms.length,
matched,
});
}
return results
.sort((left, right) => right.score - left.score || left.entry.name.localeCompare(right.entry.name))
.slice(0, query.limit ?? 100);
}
async resolve(name: string, range?: string): Promise<ResolvedRegistryEntry | null> {
const parsed = parsePluginSpecifier(range ? `${name}@${range}` : name);
const entry = (this.getManifest().plugins ?? [])
.find((plugin) => plugin.name.toLowerCase() === parsed.name.toLowerCase());
if (!entry) return null;
const resolvedVersion = resolveMarketplaceEntryVersion(entry, parsed.range);
if (!resolvedVersion) return null;
const registryEntry = toRegistryEntry(entry);
if (!registryEntry) return null;
return {
backendId: this.id,
backendKind: this.kind,
trust: this.trust,
entry: registryEntry,
version: {
version: resolvedVersion.version,
source: resolvedVersion.source,
ref: resolvedVersion.ref,
integrity: resolvedVersion.archiveIntegrity,
manifestDigest: resolvedVersion.manifestDigest,
deprecated: resolvedVersion.deprecated,
},
source: resolvedVersion.source,
ref: resolvedVersion.ref,
integrity: resolvedVersion.archiveIntegrity,
manifestDigest: resolvedVersion.manifestDigest,
};
}
async manifest(name: string, version: string): Promise<RegistryEntry | null> {
const resolved = await this.resolve(name, version);
return resolved?.entry ?? null;
}
async doctor(): Promise<RegistryDoctorReport> {
const issues = [];
const plugins = this.getManifest().plugins ?? [];
for (const entry of plugins) {
if (!/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(entry.name)) {
issues.push({
severity: 'error' as const,
code: 'invalid-name',
message: 'Registry plugin name must be vendor/plugin-name.',
pluginName: entry.name,
});
}
if (!entry.source && !entry.dist?.archive) {
issues.push({
severity: 'error' as const,
code: 'missing-source',
message: 'Registry entry must provide source or dist.archive.',
pluginName: entry.name,
});
}
if (!entry.license) {
issues.push({
severity: 'warning' as const,
code: 'missing-license',
message: 'Registry entry should declare a license.',
pluginName: entry.name,
});
}
if (!entry.capabilitiesSummary || entry.capabilitiesSummary.length === 0) {
issues.push({
severity: 'warning' as const,
code: 'missing-capabilities',
message: 'Registry entry should summarize plugin capabilities.',
pluginName: entry.name,
});
}
if (entry.yanked && !entry.yankReason) {
issues.push({
severity: 'error' as const,
code: 'missing-yank-reason',
message: 'Yanked entries must keep a human-readable reason.',
pluginName: entry.name,
});
}
}
return {
ok: !issues.some((issue) => issue.severity === 'error'),
backendId: this.id,
checkedAt: Date.now(),
entriesChecked: plugins.length,
issues,
};
}
protected getManifest(): MarketplaceManifest {
return this.manifestData;
}
}
export function toRegistryEntry(entry: MarketplacePluginEntry): RegistryEntry | null {
const parsed = RegistryEntrySchema.safeParse({
...entry,
publisher: normalizePublisher(entry.publisher),
});
return parsed.success ? parsed.data : null;
}
function normalizePublisher(publisher: MarketplacePluginEntry['publisher']) {
if (!publisher) return undefined;
return {
id: publisher.id,
github: publisher.github,
url: publisher.url,
};
}

View File

@@ -0,0 +1,126 @@
import type { MarketplacePluginEntry } from '@open-design/contracts';
export interface ParsedPluginSpecifier {
name: string;
range?: string;
}
export interface ResolvedMarketplaceVersion {
version: string;
source: string;
ref?: string;
manifestDigest?: string;
archiveIntegrity?: string;
deprecated?: boolean | string;
}
export function parsePluginSpecifier(input: string): ParsedPluginSpecifier {
const trimmed = input.trim();
const slash = trimmed.indexOf('/');
const at = trimmed.lastIndexOf('@');
if (slash > 0 && at > slash + 1) {
const range = trimmed.slice(at + 1);
return range
? { name: trimmed.slice(0, at), range }
: { name: trimmed.slice(0, at) };
}
return { name: trimmed };
}
export function resolveMarketplaceEntryVersion(
entry: MarketplacePluginEntry,
requestedRange?: string,
): ResolvedMarketplaceVersion | null {
if (entry.yanked) return null;
const versions = entry.versions ?? [];
const range = requestedRange?.trim();
const defaultVersion =
entry.distTags?.latest ??
entry.version ??
versions.find((version) => !version.yanked)?.version;
const targetVersion = range && range !== 'latest'
? resolveRequestedVersion(versions, entry.distTags ?? {}, range)
: defaultVersion;
if (!targetVersion) return null;
const versionRecord = versions.find((version) => version.version === targetVersion);
if (versionRecord?.yanked) return null;
const source = versionRecord?.source ?? entry.source;
if (!source) return null;
const resolved: ResolvedMarketplaceVersion = {
version: targetVersion,
source,
};
const ref = versionRecord?.ref ?? entry.ref;
if (ref) resolved.ref = ref;
const manifestDigest =
versionRecord?.manifestDigest ??
versionRecord?.dist?.manifestDigest ??
entry.manifestDigest ??
entry.dist?.manifestDigest;
if (manifestDigest) resolved.manifestDigest = manifestDigest;
const archiveIntegrity =
versionRecord?.integrity ??
versionRecord?.dist?.integrity ??
entry.integrity ??
entry.dist?.integrity;
if (archiveIntegrity) resolved.archiveIntegrity = archiveIntegrity;
const deprecated = versionRecord?.deprecated ?? entry.deprecated;
if (deprecated !== undefined) resolved.deprecated = deprecated;
return resolved;
}
function resolveRequestedVersion(
versions: NonNullable<MarketplacePluginEntry['versions']>,
distTags: Record<string, string>,
range: string,
): string | null {
const tagged = distTags[range];
if (tagged) return tagged;
if (!range.startsWith('^') && !range.startsWith('~')) {
return range;
}
const base = parseSemver(range.slice(1));
if (!base) return null;
const candidates = versions
.filter((version) => !version.yanked)
.map((version) => version.version)
.filter((version) => {
const parsed = parseSemver(version);
if (!parsed) return false;
if (range.startsWith('^')) {
return parsed.major === base.major && compareSemver(parsed, base) >= 0;
}
return parsed.major === base.major &&
parsed.minor === base.minor &&
compareSemver(parsed, base) >= 0;
})
.sort((left, right) => compareSemver(parseSemver(right)!, parseSemver(left)!));
return candidates[0] ?? null;
}
interface SemverParts {
major: number;
minor: number;
patch: number;
}
function parseSemver(value: string): SemverParts | null {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value);
if (!match) return null;
return {
major: Number(match[1] ?? 0),
minor: Number(match[2] ?? 0),
patch: Number(match[3] ?? 0),
};
}
function compareSemver(left: SemverParts, right: SemverParts): number {
return left.major - right.major ||
left.minor - right.minor ||
left.patch - right.patch;
}

View File

@@ -59,6 +59,7 @@ import {
MissingInputError,
pluginPromptBlock,
pruneExpiredSnapshots,
readPluginLockfile,
registerBuiltInAtomWorkers,
registerBundledPlugins,
resolvePluginSnapshot,
@@ -806,10 +807,10 @@ const PROMPT_TEMPLATES_DIR = resolveDaemonResourceDir(
'prompt-templates',
path.join(PROJECT_ROOT, 'prompt-templates'),
);
const COMMUNITY_DIR = resolveDaemonResourceDir(
const PLUGIN_REGISTRY_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'community',
path.join(PROJECT_ROOT, 'community'),
'plugins/registry',
path.join(PROJECT_ROOT, 'plugins', 'registry'),
);
const OFFICIAL_MARKETPLACE_ID = 'official';
const OFFICIAL_MARKETPLACE_URL = 'https://open-design.ai/marketplace/open-design-marketplace.json';
@@ -879,6 +880,7 @@ export function resolveDataDir(raw, projectRoot) {
return resolved;
}
const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT);
const PLUGIN_LOCKFILE_PATH = path.join(RUNTIME_DATA_DIR, 'od-plugin-lock.json');
// Canonical (realpath-resolved) form of RUNTIME_DATA_DIR for the few callers
// that compare it against a user-supplied realpath() result. On macOS, /var
// is a symlink to /private/var, so an import realpath lands in /private/var
@@ -2335,7 +2337,7 @@ export async function startServer({
}
try {
const seedDirs = await fs.promises.readdir(COMMUNITY_DIR, { withFileTypes: true }).catch((err) => {
const seedDirs = await fs.promises.readdir(PLUGIN_REGISTRY_DIR, { withFileTypes: true }).catch((err) => {
if (err?.code === 'ENOENT') return [];
throw err;
});
@@ -2343,7 +2345,7 @@ export async function startServer({
for (const dirent of seedDirs) {
if (!dirent.isDirectory()) continue;
const id = dirent.name;
const manifestPath = path.join(COMMUNITY_DIR, id, 'open-design-marketplace.json');
const manifestPath = path.join(PLUGIN_REGISTRY_DIR, id, 'open-design-marketplace.json');
if (!fs.existsSync(manifestPath)) continue;
let manifestText = await fs.promises.readFile(manifestPath, 'utf8');
if (id === OFFICIAL_MARKETPLACE_ID && bundledMarketplaceEntries.length > 0) {
@@ -3841,6 +3843,7 @@ export async function startServer({
source,
_stagedFolder: pluginRoot,
_stagedSourceKind: 'user',
lockfilePath: PLUGIN_LOCKFILE_PATH,
})) {
if (ev.message) log.push(ev.message);
if (Array.isArray(ev.warnings)) warnings.splice(0, warnings.length, ...ev.warnings);
@@ -4012,7 +4015,13 @@ export async function startServer({
// the same byte path that would happen if the user copy-pasted
// the source manually.
const { resolvePluginInMarketplaces } = await import('./plugins/marketplaces.js');
const resolved = resolvePluginInMarketplaces(db, source);
let lookupName = source;
const lockfile = await readPluginLockfile(PLUGIN_LOCKFILE_PATH);
const locked = lockfile.plugins[source];
if (locked?.version && !source.includes('@')) {
lookupName = `${source}@${locked.version}`;
}
const resolved = resolvePluginInMarketplaces(db, lookupName);
if (!resolved) {
return res.status(404).json({
error: {
@@ -4046,6 +4055,7 @@ export async function startServer({
resolvedRef: marketplaceResolution?.ref,
manifestDigest: marketplaceResolution?.manifestDigest,
archiveIntegrity: marketplaceResolution?.archiveIntegrity,
lockfilePath: PLUGIN_LOCKFILE_PATH,
})) {
writeEvent(ev.kind, ev);
if (ev.kind === 'success' || ev.kind === 'error') break;
@@ -4081,6 +4091,8 @@ export async function startServer({
// daemon's authoritative copy and confuse the next boot.
app.post('/api/plugins/:id/upgrade', async (req, res) => {
const id = req.params.id;
const body = req.body && typeof req.body === 'object' ? req.body : {};
const policy = body.policy === 'pinned' ? 'pinned' : 'latest';
const plugin = getInstalledPlugin(db, id);
if (!plugin) {
return res.status(404).json({
@@ -4096,7 +4108,24 @@ export async function startServer({
},
});
}
const source = plugin.source;
let source = plugin.source;
let marketplaceResolution: {
marketplaceId: string;
marketplaceTrust: 'official' | 'trusted' | 'restricted';
pluginName: string;
pluginVersion: string;
source: string;
ref?: string;
manifestDigest?: string;
archiveIntegrity?: string;
} | null = null;
if (policy === 'latest' && plugin.sourceMarketplaceEntryName) {
const { resolvePluginInMarketplaces } = await import('./plugins/marketplaces.js');
marketplaceResolution = resolvePluginInMarketplaces(db, plugin.sourceMarketplaceEntryName);
if (marketplaceResolution) {
source = marketplaceResolution.source;
}
}
if (!source) {
return res.status(409).json({
error: {
@@ -4116,20 +4145,21 @@ export async function startServer({
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
writeEvent('progress', { kind: 'progress', phase: 'resolving', message: `Upgrading ${id} from ${source}` });
writeEvent('progress', { kind: 'progress', phase: 'resolving', message: `Upgrading ${id} from ${source} (policy=${policy})` });
try {
for await (const ev of installPlugin(db, {
source,
eventKind: 'upgraded',
sourceMarketplaceId: plugin.sourceMarketplaceId,
sourceMarketplaceEntryName: plugin.sourceMarketplaceEntryName,
sourceMarketplaceEntryVersion: plugin.sourceMarketplaceEntryVersion,
marketplaceTrust: plugin.marketplaceTrust,
resolvedSource: plugin.resolvedSource,
resolvedRef: plugin.resolvedRef,
manifestDigest: plugin.manifestDigest,
archiveIntegrity: plugin.archiveIntegrity,
sourceMarketplaceId: marketplaceResolution?.marketplaceId ?? plugin.sourceMarketplaceId,
sourceMarketplaceEntryName: marketplaceResolution?.pluginName ?? plugin.sourceMarketplaceEntryName,
sourceMarketplaceEntryVersion: marketplaceResolution?.pluginVersion ?? plugin.sourceMarketplaceEntryVersion,
marketplaceTrust: marketplaceResolution?.marketplaceTrust ?? plugin.marketplaceTrust,
resolvedSource: marketplaceResolution?.source ?? plugin.resolvedSource,
resolvedRef: marketplaceResolution?.ref ?? plugin.resolvedRef,
manifestDigest: marketplaceResolution?.manifestDigest ?? plugin.manifestDigest,
archiveIntegrity: marketplaceResolution?.archiveIntegrity ?? plugin.archiveIntegrity,
lockfilePath: PLUGIN_LOCKFILE_PATH,
})) {
writeEvent(ev.kind, ev);
if (ev.kind === 'success' || ev.kind === 'error') break;

View File

@@ -14,6 +14,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import url from 'node:url';
import { createHash } from 'node:crypto';
import { Readable } from 'node:stream';
import { mkdtemp, rm, writeFile, mkdir, symlink, readdir } from 'node:fs/promises';
import Database from 'better-sqlite3';
@@ -122,13 +123,35 @@ describe('archive installer', () => {
if (ev.kind === 'success') success = true;
}
expect(success).toBe(true);
const row = db.prepare(`SELECT source_kind, source FROM installed_plugins WHERE id = 'sample-plugin'`).get();
const row = db.prepare(`SELECT source_kind, source, archive_integrity FROM installed_plugins WHERE id = 'sample-plugin'`).get() as {
source_kind: string;
source: string;
archive_integrity: string;
};
expect(row).toEqual({
source_kind: 'url',
source: 'https://example.com/sample-plugin-1.0.0.tgz',
archive_integrity: `sha256:${createHash('sha256').update(tarball).digest('hex')}`,
});
});
it('rejects archive downloads when marketplace integrity does not match', async () => {
const tarball = await buildFixtureTarball({ rootPrefix: 'sample-plugin-1.0.0' });
let success = false;
let error: string | undefined;
for await (const ev of installPlugin(db, {
source: 'https://example.com/sample-plugin-1.0.0.tgz',
roots: { userPluginsRoot: pluginsRoot },
fetcher: makeFetcher(tarball),
archiveIntegrity: 'sha256:deadbeef',
})) {
if (ev.kind === 'success') success = true;
if (ev.kind === 'error') error = ev.message;
}
expect(success).toBe(false);
expect(error).toMatch(/integrity mismatch/);
});
it('rejects archives that exceed the size cap', async () => {
const tarball = await buildFixtureTarball({
rootPrefix: 'sample-plugin-fat',

View File

@@ -4,7 +4,7 @@
// arrival lands in Phase 2A.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
@@ -71,6 +71,8 @@ describe('installFromLocalFolder', () => {
const list = listInstalledPlugins(db);
expect(list).toHaveLength(1);
expect(list[0]?.id).toBe('sample-plugin');
expect(list[0]?.sourceKind).toBe('local');
expect(list[0]?.trust).toBe('restricted');
expect(list[0]?.fsPath).toBe(path.join(pluginsRoot, 'sample-plugin'));
});
@@ -105,6 +107,7 @@ describe('installFromLocalFolder', () => {
});
it('persists marketplace provenance and inherited trust for resolved installs', async () => {
const lockfilePath = path.join(tmpRoot, '.od', 'od-plugin-lock.json');
const manifest = JSON.stringify({
specVersion: '1.0.0',
name: 'fixture-registry',
@@ -147,6 +150,7 @@ describe('installFromLocalFolder', () => {
resolvedRef: resolved!.ref!,
manifestDigest: resolved!.manifestDigest!,
archiveIntegrity: resolved!.archiveIntegrity!,
lockfilePath,
})) {
if (ev.kind === 'success') installedRecord = ev.plugin;
if (ev.kind === 'error') throw new Error(ev.message);
@@ -168,6 +172,16 @@ describe('installFromLocalFolder', () => {
expect(row?.sourceMarketplaceId).toBe(added.row.id);
expect(row?.marketplaceTrust).toBe('official');
expect(row?.trust).toBe('trusted');
const lockfile = JSON.parse(await readFile(lockfilePath, 'utf8'));
expect(lockfile.plugins['vendor/sample-plugin']).toMatchObject({
name: 'vendor/sample-plugin',
version: '1.0.0',
sourceMarketplaceId: added.row.id,
sourceMarketplaceEntryName: 'vendor/sample-plugin',
resolvedRef: 'abc123',
manifestDigest: 'sha256-manifest',
archiveIntegrity: 'sha512-fixture',
});
});
it('keeps restricted marketplace installs restricted', async () => {

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { InstalledPluginRecord } from '@open-design/contracts';
import {
lockEntryFromInstalled,
readPluginLockfile,
upsertPluginLockfileEntry,
} from '../src/plugins/lockfile.js';
const plugin: InstalledPluginRecord = {
id: 'registry-starter',
title: 'Registry starter',
version: '0.1.0',
sourceKind: 'github',
source: 'github:nexu-io/open-design@main/plugins/community/registry-starter',
sourceMarketplaceId: 'community',
sourceMarketplaceEntryName: 'community/registry-starter',
sourceMarketplaceEntryVersion: '0.1.0',
marketplaceTrust: 'restricted',
resolvedSource: 'github:nexu-io/open-design@main/plugins/community/registry-starter',
resolvedRef: 'main',
manifestDigest: 'sha256:manifest',
archiveIntegrity: 'sha256:archive',
trust: 'restricted',
capabilitiesGranted: ['prompt:inject'],
manifest: {
specVersion: '1.0.0',
name: 'registry-starter',
version: '0.1.0',
title: 'Registry starter',
},
fsPath: '/tmp/registry-starter',
installedAt: 1,
updatedAt: 1,
};
describe('plugin lockfile', () => {
it('converts installed provenance into a reproducible lock entry', () => {
expect(lockEntryFromInstalled(plugin, 123)).toMatchObject({
name: 'community/registry-starter',
version: '0.1.0',
sourceMarketplaceId: 'community',
resolvedRef: 'main',
manifestDigest: 'sha256:manifest',
archiveIntegrity: 'sha256:archive',
lockedAt: 123,
});
});
it('writes stable .od/od-plugin-lock.json content', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'od-lock-'));
try {
const filePath = path.join(dir, '.od', 'od-plugin-lock.json');
await upsertPluginLockfileEntry(filePath, plugin, 123);
expect(await readPluginLockfile(filePath)).toMatchObject({
schemaVersion: 1,
plugins: {
'community/registry-starter': {
source: plugin.source,
archiveIntegrity: 'sha256:archive',
},
},
});
expect(await readFile(filePath, 'utf8')).toContain('"schemaVersion": 1');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { doctorMarketplace } from '../src/plugins/marketplace-doctor.js';
describe('marketplace doctor', () => {
it('reports registry-grade issues for catalogs', async () => {
const report = await doctorMarketplace({
id: 'community',
trust: 'restricted',
checkedAt: 123,
strict: true,
manifest: {
specVersion: '1.0.0',
name: 'community',
version: '1.0.0',
plugins: [
{
name: 'bad-flat-name',
version: '0.1.0',
source: 'github:example/bad',
dist: { archive: 'https://example.com/bad.tgz' },
},
{
name: 'vendor/good',
version: '1.0.0',
source: 'github:vendor/good',
license: 'MIT',
capabilitiesSummary: ['prompt:inject'],
publisher: { id: 'vendor' },
},
],
},
});
expect(report.ok).toBe(false);
expect(report.checkedAt).toBe(123);
expect(report.issues.map((issue) => issue.code)).toEqual(
expect.arrayContaining([
'invalid-name',
'archive-integrity-required',
'missing-license',
'missing-capabilities',
'missing-publisher',
]),
);
});
});

View File

@@ -6,7 +6,7 @@
// will read against.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
@@ -75,6 +75,44 @@ describe('marketplaces', () => {
expect(listMarketplaces(db)).toHaveLength(1);
});
it('resolves marketplace names with exact versions, dist-tags, ranges, and yanks', async () => {
const manifest = JSON.stringify({
specVersion: '1.0.0',
name: 'versions',
version: '1.0.0',
plugins: [
{
name: 'vendor/ranged',
source: 'github:vendor/ranged@v1.2.0/plugin',
version: '1.2.0',
distTags: { latest: '1.2.0', beta: '2.0.0' },
versions: [
{ version: '1.0.0', source: 'github:vendor/ranged@v1.0.0/plugin', integrity: 'sha256:one' },
{ version: '1.1.0', source: 'github:vendor/ranged@v1.1.0/plugin', integrity: 'sha256:two' },
{ version: '1.2.0', source: 'github:vendor/ranged@v1.2.0/plugin', integrity: 'sha256:three' },
{ version: '2.0.0', source: 'github:vendor/ranged@v2.0.0/plugin', yanked: true },
],
},
],
});
const seeded = ensureMarketplaceManifest(db, {
id: 'versions',
url: 'https://example.com/versions.json',
trust: 'trusted',
manifestText: manifest,
});
if (!seeded.ok) throw new Error('seed failed');
expect(resolvePluginInMarketplaces(db, 'vendor/ranged')?.pluginVersion).toBe('1.2.0');
expect(resolvePluginInMarketplaces(db, 'vendor/ranged@1.0.0')).toMatchObject({
pluginVersion: '1.0.0',
source: 'github:vendor/ranged@v1.0.0/plugin',
archiveIntegrity: 'sha256:one',
});
expect(resolvePluginInMarketplaces(db, 'vendor/ranged@^1.0.0')?.pluginVersion).toBe('1.2.0');
expect(resolvePluginInMarketplaces(db, 'vendor/ranged@beta')).toBeNull();
});
it('addMarketplace rejects non-https urls', async () => {
const result = await addMarketplace(db, {
url: 'http://example.com/marketplace.json',
@@ -98,6 +136,19 @@ describe('marketplaces', () => {
}
});
it('requires a raw open-design-marketplace.json document, not a GitHub tree page', async () => {
const result = await addMarketplace(db, {
url: 'https://github.com/nexu-io/open-design/tree/garnet-hemisphere/plugins/registry/community',
fetcher: fixtureFetcher('<!doctype html><html><body>GitHub tree page</body></html>'),
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(422);
expect(result.message).toMatch(/validation/i);
}
});
it('refresh re-fetches and updates refreshed_at', async () => {
const added = await addMarketplace(db, {
url: 'https://example.com/marketplace.json',
@@ -165,6 +216,90 @@ describe('marketplaces', () => {
expect(updated.row.refreshedAt).toBe(456);
expect(updated.row.version).toBe('1.0.1');
});
it('seeds the checked-in default community registry as restricted and resolvable', async () => {
const communityManifest = await readFile(
new URL('../../../plugins/registry/community/open-design-marketplace.json', import.meta.url),
'utf8',
);
const seeded = ensureMarketplaceManifest(db, {
id: 'community',
url: 'https://open-design.ai/marketplace/community/open-design-marketplace.json',
trust: 'restricted',
manifestText: communityManifest,
now: 123,
});
if (!seeded.ok) throw new Error('community seed failed');
expect(seeded.row.trust).toBe('restricted');
const resolved = resolvePluginInMarketplaces(db, 'community/registry-starter');
expect(resolved?.marketplaceId).toBe('community');
expect(resolved?.marketplaceTrust).toBe('restricted');
expect(resolved?.source).toMatch(
/^github:nexu-io\/open-design(?:@[^/]+)?\/plugins\/community\/registry-starter$/,
);
});
it('keeps the checked-in official registry populated from bundled plugins', async () => {
const officialManifestText = await readFile(
new URL('../../../plugins/registry/official/open-design-marketplace.json', import.meta.url),
'utf8',
);
const officialManifest = JSON.parse(officialManifestText) as {
trust?: string;
metadata?: { bundledPreinstallCount?: number };
plugins?: Array<{ name?: string; source?: string }>;
};
expect(officialManifest.trust).toBe('official');
expect(officialManifest.plugins?.length).toBeGreaterThan(100);
expect(officialManifest.metadata?.bundledPreinstallCount).toBe(
officialManifest.plugins?.length,
);
expect(officialManifest.plugins?.some((plugin) => plugin.name === 'open-design/build-test')).toBe(true);
expect(officialManifest.plugins?.every((plugin) =>
/^github:nexu-io\/open-design(?:@[^/]+)?\/plugins\/_official\//.test(plugin.source ?? ''),
)).toBe(true);
const seeded = ensureMarketplaceManifest(db, {
id: 'official',
url: 'https://open-design.ai/marketplace/open-design-marketplace.json',
trust: 'official',
manifestText: officialManifestText,
now: 123,
});
if (!seeded.ok) throw new Error('official seed failed');
const resolved = resolvePluginInMarketplaces(db, 'open-design/build-test');
expect(resolved?.marketplaceId).toBe('official');
expect(resolved?.marketplaceTrust).toBe('official');
});
it('keeps checked-in community registry entries pointed at source folders that can pack', async () => {
const communityManifest = JSON.parse(await readFile(
new URL('../../../plugins/registry/community/open-design-marketplace.json', import.meta.url),
'utf8',
)) as {
plugins?: Array<{ name?: string; source?: string }>;
};
const entry = communityManifest.plugins?.find((plugin) => plugin.name === 'community/registry-starter');
expect(entry?.source).toBeTruthy();
const sourceSubpath = entry!.source!.replace(/^github:nexu-io\/open-design(?:@[^/]+)?\//, '');
expect(sourceSubpath).toBe('plugins/community/registry-starter');
const sourceManifest = await readFile(
new URL(`../../../${sourceSubpath}/open-design.json`, import.meta.url),
'utf8',
);
expect(JSON.parse(sourceManifest)).toMatchObject({
name: 'community-registry-starter',
plugin: {
repo: expect.stringContaining('github.com/nexu-io/open-design'),
},
});
});
});
describe('resolvePluginInMarketplaces', () => {

View File

@@ -10,10 +10,11 @@ import {
buildPublishLink,
PublishError,
PUBLISH_TARGETS,
upsertMarketplaceJsonEntry,
} from '../src/plugins/publish.js';
const META = {
pluginId: 'sample-plugin',
pluginId: 'open-design/sample-plugin',
pluginVersion: '1.0.0',
pluginTitle: 'Sample Plugin',
pluginDescription: 'A fixture for the publish flow.',
@@ -77,3 +78,76 @@ describe('buildPublishLink', () => {
expect(() => buildPublishLink({ catalog: 'mystery' as never, meta: META })).toThrow(PublishError);
});
});
describe('upsertMarketplaceJsonEntry', () => {
it('adds a namespaced plugin entry with a reproducible github source', () => {
const outcome = upsertMarketplaceJsonEntry({
generatedAt: '2026-05-14T00:00:00.000Z',
manifest: {
specVersion: '1.0.0',
name: 'community',
version: '0.1.0',
plugins: [],
},
meta: META,
});
expect(outcome.inserted).toBe(true);
expect(outcome.entry).toMatchObject({
name: 'open-design/sample-plugin',
source: 'github:open-design/sample-plugin',
version: '1.0.0',
title: 'Sample Plugin',
publisher: {
github: 'open-design',
},
});
expect(outcome.manifest.plugins).toHaveLength(1);
expect(outcome.manifest.generatedAt).toBe('2026-05-14T00:00:00.000Z');
});
it('updates existing entries and preserves unrelated catalog metadata', () => {
const outcome = upsertMarketplaceJsonEntry({
generatedAt: '2026-05-14T00:00:00.000Z',
manifest: {
specVersion: '1.0.0',
name: 'community',
version: '0.1.0',
extra: true,
plugins: [
{
name: 'open-design/sample-plugin',
source: 'github:open-design/sample-plugin@old',
version: '0.9.0',
tags: ['kept'],
},
],
},
meta: {
...META,
pluginVersion: '1.1.0',
repoUrl: 'https://github.com/open-design/sample-plugin/tree/main/plugins/sample',
},
});
expect(outcome.inserted).toBe(false);
expect(outcome.manifest.extra).toBe(true);
expect(outcome.manifest.plugins[0]).toMatchObject({
name: 'open-design/sample-plugin',
source: 'github:open-design/sample-plugin@main/plugins/sample',
version: '1.1.0',
tags: ['kept'],
});
});
it('rejects flat ids for public marketplace JSON', () => {
expect(() => upsertMarketplaceJsonEntry({
manifest: { plugins: [] },
meta: {
pluginId: 'sample-plugin',
pluginVersion: '1.0.0',
repoUrl: 'https://github.com/open-design/sample-plugin',
},
})).toThrow(PublishError);
});
});

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import type { MarketplaceManifest } from '@open-design/contracts';
import { StaticRegistryBackend } from '../src/registry/static-backend.js';
import {
DatabaseRegistryBackend,
ensureRegistryTables,
upsertRegistryEntry,
} from '../src/registry/database-backend.js';
import { GithubRegistryBackend, type GithubRegistryClient } from '../src/registry/github-backend.js';
const manifest: MarketplaceManifest = {
specVersion: '1.0.0',
name: 'fixture',
version: '1.0.0',
plugins: [
{
name: 'vendor/example',
title: 'Example',
description: 'Searchable fixture plugin',
version: '1.1.0',
source: 'github:vendor/example@v1.1.0/plugin',
versions: [
{
version: '1.0.0',
source: 'github:vendor/example@v1.0.0/plugin',
integrity: 'sha256:old',
},
{
version: '1.1.0',
source: 'github:vendor/example@v1.1.0/plugin',
integrity: 'sha256:new',
},
],
distTags: { latest: '1.1.0' },
license: 'MIT',
capabilitiesSummary: ['prompt:inject'],
tags: ['fixture'],
},
],
};
describe('registry backends', () => {
it('resolves exact versions and dist-tags from static manifests', async () => {
const backend = new StaticRegistryBackend({
id: 'fixture',
trust: 'trusted',
manifest,
});
await expect(backend.resolve('vendor/example')).resolves.toMatchObject({
source: 'github:vendor/example@v1.1.0/plugin',
integrity: 'sha256:new',
});
await expect(backend.resolve('vendor/example@1.0.0')).resolves.toMatchObject({
source: 'github:vendor/example@v1.0.0/plugin',
integrity: 'sha256:old',
});
});
it('keeps database backend behavior equivalent to static backend', async () => {
const db = new Database(':memory:');
try {
ensureRegistryTables(db);
const staticBackend = new StaticRegistryBackend({
id: 'fixture',
trust: 'restricted',
manifest,
});
for (const entry of await staticBackend.list()) {
upsertRegistryEntry(db, 'fixture', entry, 123);
}
const databaseBackend = new DatabaseRegistryBackend({ id: 'fixture', db });
await expect(databaseBackend.list()).resolves.toEqual(await staticBackend.list());
await expect(databaseBackend.search({ query: 'Searchable' })).resolves.toMatchObject([
{ entry: { name: 'vendor/example' } },
]);
await expect(databaseBackend.resolve('vendor/example')).resolves.toMatchObject({
source: 'github:vendor/example@v1.1.0/plugin',
});
} finally {
db.close();
}
});
it('builds GitHub publish PR mutations with deterministic paths', async () => {
let mutationFiles: string[] = [];
const client: GithubRegistryClient = {
async readMarketplace() {
return manifest;
},
async createPublishPullRequest(mutation) {
mutationFiles = mutation.files.map((file) => file.path);
return { url: 'https://github.com/open-design/plugin-registry/pull/1' };
},
};
const backend = await GithubRegistryBackend.create({
id: 'official',
owner: 'open-design',
repo: 'plugin-registry',
client,
});
const entry = (await backend.list())[0];
if (!entry) throw new Error('fixture entry missing');
await expect(backend.publish?.({ entry })).resolves.toMatchObject({
ok: true,
pullRequestUrl: 'https://github.com/open-design/plugin-registry/pull/1',
});
expect(mutationFiles).toEqual([
'plugins/vendor/example/entry.json',
'plugins/vendor/example/versions/1.1.0.json',
]);
});
});

View File

@@ -23,6 +23,10 @@ It is the deployable counterpart to:
uses React only at build time (`renderToStaticMarkup`) for the existing
`app/page.tsx` component. The generated page is CDN-ready HTML/CSS plus
a small inline enhancement script; no React runtime ships to browsers.
- Public plugin registry pages live under `app/pages/plugins/`. They are
generated statically from repository-owned registry files and bundled plugin
manifests at build time, so open-design.ai can expose searchable,
indexable ecosystem pages without daemon/API coupling.
- `astro.config.ts` always uses `output: 'static'` and emits to `out/`
so it can be served by any CDN (Vercel, Cloudflare Pages, the daemon's
static fallback) without a Node runtime.
@@ -38,9 +42,8 @@ It is the deployable counterpart to:
not state, routes, or runtime.
- Not connected to `apps/daemon`. There is no `/api`, no `/artifacts`,
no `/frames` — no proxy to set up.
- Not multi-page. There is exactly one route (`/`) that renders the
full landing page. If you need a second page, add it as a sibling
Astro page route.
- Not a product app router. Static sibling Astro routes are allowed for public
marketing or SEO surfaces, but they must remain build-time/static.
## Boundary constraints

View File

@@ -9,6 +9,7 @@ const REPO = 'https://github.com/nexu-io/open-design';
const REPO_RELEASES = `${REPO}/releases`;
const REPO_SKILLS = `${REPO}/tree/main/skills`;
const REPO_DESIGN_SYSTEMS = `${REPO}/tree/main/design-systems`;
const PLUGINS_PAGE = '/plugins/';
const ext = {
target: '_blank',
@@ -19,7 +20,7 @@ export function Header() {
return (
<header className='nav' data-od-id='nav' data-nav-headroom>
<div className='container nav-inner'>
<a href='#top' className='brand'>
<a href='/' className='brand'>
<span className='brand-mark'>Ø</span>
<span>Open Design</span>
<span className='brand-meta'>
@@ -28,6 +29,11 @@ export function Header() {
</a>
<nav>
<ul className='nav-links'>
<li>
<a href={PLUGINS_PAGE}>
Plugins<span className='num'>Registry</span>
</a>
</li>
<li>
<a href={REPO_SKILLS} {...ext}>
Skills<span className='num'>31</span>
@@ -39,17 +45,17 @@ export function Header() {
</a>
</li>
<li>
<a href='#agents'>
<a href='/#agents'>
Agents<span className='num'>12</span>
</a>
</li>
<li>
<a href='#labs'>
<a href='/#labs'>
Labs<span className='num'>05</span>
</a>
</li>
<li>
<a href='#contact'>Contact</a>
<a href='/#contact'>Contact</a>
</li>
</ul>
</nav>

View File

@@ -2052,3 +2052,417 @@ footer {
.work-card { padding: 26px 22px; }
.work-card.alt { padding: 24px 20px; }
}
/* ====== Plugin registry pages ====== */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.plugin-shell .nav { border-bottom: 1px solid var(--line-soft); }
.plugin-directory,
.plugin-detail { padding-bottom: 96px; }
.plugin-hero,
.plugin-detail-hero {
padding: 96px 0 72px;
border-bottom: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(247, 241, 222, 0.74), rgba(239, 231, 210, 0.4)),
var(--paper);
}
.plugin-hero__grid,
.plugin-detail-hero__grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 56px;
align-items: start;
}
.plugin-hero h1,
.plugin-detail h1 {
max-width: 900px;
margin-top: 18px;
font-family: var(--serif);
font-size: clamp(48px, 7vw, 96px);
line-height: 0.94;
letter-spacing: 0;
}
.plugin-hero p,
.plugin-detail-hero p,
.plugin-detail-panel p {
max-width: 760px;
margin-top: 24px;
color: var(--ink-mute);
font-size: 18px;
line-height: 1.65;
}
.plugin-hero__actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 34px;
}
.plugin-hero__panel,
.plugin-detail__facts,
.plugin-detail-panel {
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(247, 241, 222, 0.72);
box-shadow: var(--shadow);
}
.plugin-hero__panel {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
overflow: hidden;
}
.plugin-hero__panel div {
min-height: 150px;
padding: 22px;
background: rgba(255, 255, 255, 0.18);
border-bottom: 1px solid var(--line-soft);
}
.plugin-hero__panel span {
display: block;
font-family: var(--serif);
font-size: 56px;
line-height: 0.9;
}
.plugin-hero__panel small {
display: block;
margin-top: 14px;
color: var(--ink-mute);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.plugin-hero__panel code {
grid-column: 1 / -1;
display: block;
padding: 18px 22px 22px;
color: var(--ink-soft);
font-family: var(--mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.plugin-registry-section,
.plugin-detail-section {
padding: 64px 0 0;
}
.plugin-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
gap: 24px;
align-items: end;
}
.plugin-toolbar h2,
.plugin-detail-panel h2 {
margin-top: 8px;
font-family: var(--serif);
font-size: clamp(34px, 4vw, 54px);
line-height: 1;
letter-spacing: 0;
}
.plugin-search input {
width: 100%;
min-height: 54px;
padding: 0 18px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(247, 241, 222, 0.86);
color: var(--ink);
font: 500 15px var(--body);
outline: none;
}
.plugin-search input:focus {
border-color: rgba(237, 111, 92, 0.72);
box-shadow: 0 0 0 3px rgba(237, 111, 92, 0.16);
}
.plugin-filter-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 28px;
}
.plugin-filter {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(247, 241, 222, 0.65);
color: var(--ink-mute);
font: 600 14px var(--body);
cursor: pointer;
}
.plugin-filter span { color: var(--ink-faint); }
.plugin-filter.is-active {
background: var(--ink);
color: var(--bone);
border-color: var(--ink);
}
.plugin-filter.is-active span { color: rgba(247, 241, 222, 0.72); }
.plugin-result-count {
margin-top: 18px;
color: var(--ink-mute);
font-size: 14px;
}
.plugin-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-top: 24px;
}
.plugin-card-grid.compact { margin-top: 22px; }
.plugin-card {
min-height: 285px;
display: flex;
flex-direction: column;
gap: 14px;
padding: 20px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(247, 241, 222, 0.66);
box-shadow: 0 18px 36px -30px rgba(21, 20, 15, 0.22);
}
.plugin-card[hidden] { display: none; }
.plugin-card__meta,
.plugin-card__footer,
.plugin-detail__badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.plugin-card__meta {
justify-content: space-between;
color: var(--ink-faint);
font: 500 12px var(--mono);
}
.plugin-badge,
.plugin-detail__badges span {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--ink-soft);
background: rgba(255, 255, 255, 0.24);
font: 700 11px var(--body);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.plugin-badge--official {
color: #6f2c1f;
border-color: rgba(237, 111, 92, 0.35);
background: rgba(237, 111, 92, 0.14);
}
.plugin-badge--community {
color: #454c20;
border-color: rgba(110, 116, 72, 0.38);
background: rgba(110, 116, 72, 0.14);
}
.plugin-card h3 {
font-family: var(--serif);
font-size: 28px;
line-height: 1.04;
letter-spacing: 0;
}
.plugin-card h3 a,
.plugin-card__footer a,
.plugin-back-link,
.plugin-source-list a {
color: inherit;
text-decoration-color: rgba(21, 20, 15, 0.28);
text-underline-offset: 4px;
}
.plugin-card code,
.plugin-detail__commands code,
.plugin-detail__facts dd {
font-family: var(--mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.plugin-card p {
color: var(--ink-mute);
line-height: 1.6;
}
.plugin-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.plugin-tags span {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid var(--line-soft);
border-radius: 999px;
color: var(--ink-mute);
background: rgba(255, 255, 255, 0.2);
font-size: 12px;
}
.plugin-tags--large {
margin-top: 22px;
}
.plugin-tags--large span {
min-height: 34px;
font-size: 13px;
}
.plugin-card__footer {
justify-content: space-between;
margin-top: 4px;
padding-top: 14px;
border-top: 1px solid var(--line-soft);
color: var(--ink-mute);
font-size: 13px;
}
.plugin-back-link {
display: inline-flex;
margin-bottom: 24px;
color: var(--ink-mute);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 12px;
}
.plugin-detail__commands {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: stretch;
max-width: 720px;
margin-top: 34px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(21, 20, 15, 0.05);
}
.plugin-detail__commands.compact {
display: block;
max-width: none;
margin-top: 28px;
}
.plugin-detail__commands div {
display: grid;
gap: 4px;
min-width: 0;
}
.plugin-detail__commands span {
color: var(--ink-mute);
font: 700 11px var(--body);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.plugin-detail__commands button {
min-width: 86px;
border: 1px solid rgba(237, 111, 92, 0.36);
border-radius: 8px;
background: var(--coral);
color: #fff;
font: 700 13px var(--body);
cursor: pointer;
}
.plugin-detail__facts {
padding: 22px;
}
.plugin-detail__facts dl {
display: grid;
gap: 18px;
}
.plugin-detail__facts div {
display: grid;
gap: 4px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line-soft);
}
.plugin-detail__facts div:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.plugin-detail__facts dt {
color: var(--ink-mute);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.plugin-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.plugin-detail-panel {
padding: 26px;
}
.plugin-source-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 28px;
}
.plugin-source-list a {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
font-weight: 700;
text-decoration: none;
}
.plugin-related {
padding-top: 72px;
}
@media (max-width: 980px) {
.plugin-hero__grid,
.plugin-detail-hero__grid,
.plugin-toolbar,
.plugin-detail-grid {
grid-template-columns: 1fr;
}
.plugin-hero__panel {
max-width: 640px;
}
}
@media (max-width: 640px) {
.plugin-hero,
.plugin-detail-hero {
padding: 72px 0 52px;
}
.plugin-hero h1,
.plugin-detail h1 {
font-size: clamp(40px, 12vw, 58px);
}
.plugin-hero p,
.plugin-detail-hero p,
.plugin-detail-panel p {
font-size: 16px;
}
.plugin-hero__panel {
grid-template-columns: 1fr;
}
.plugin-hero__panel div {
min-height: 110px;
}
.plugin-card {
min-height: auto;
}
.plugin-detail__commands {
grid-template-columns: 1fr;
}
.plugin-detail__commands button {
min-height: 44px;
}
}

View File

@@ -42,6 +42,7 @@ const REPO_DAEMON = `${REPO}/tree/main/apps/daemon`;
const REPO_SKILLS = `${REPO}/tree/main/skills`;
const REPO_DESIGN_SYSTEMS = `${REPO}/tree/main/design-systems`;
const REPO_DOCS = (file: string) => `${REPO}/blob/main/${file}`;
const PLUGINS_PAGE = '/plugins/';
// Lineage / inspiration projects — make every brand mention clickable.
const LINEAGE = {
@@ -174,6 +175,10 @@ export default function Page() {
Download desktop
<span className='arrow'>{arrowPlus}</span>
</a>
<a className='btn btn-ghost' href={PLUGINS_PAGE}>
Browse plugins
<span className='arrow'>{arrowPlus}</span>
</a>
</div>
<div className='hero-stats' data-reveal>
<div className='stat'>

View File

@@ -0,0 +1,285 @@
---
import '../../globals.css';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Header } from '../../_components/header';
import type { PublicPluginEntry } from '../../plugin-registry';
import { getPublicPlugins } from '../../plugin-registry';
export function getStaticPaths() {
return getPublicPlugins().map((plugin) => ({
params: { slug: plugin.slug },
props: { plugin },
}));
}
const { plugin } = Astro.props as { plugin: PublicPluginEntry };
const site = Astro.site ?? new URL('https://open-design.ai');
const canonical = new URL(plugin.detailHref, site).toString();
const title = `${plugin.title} — Open Design Plugin`;
const description = `${plugin.description} Install with ${plugin.installCommand}.`;
const headerHtml = renderToStaticMarkup(createElement(Header));
const deepLink = `od://plugins/${encodeURIComponent(plugin.id)}?source=${encodeURIComponent(plugin.registryId)}`;
const related = getPublicPlugins()
.filter((item) => item.id !== plugin.id && item.registryId === plugin.registryId)
.slice(0, 6);
const pluginJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: plugin.title,
alternateName: plugin.id,
description: plugin.description,
applicationCategory: 'DesignApplication',
operatingSystem: 'macOS, Windows, Linux',
softwareVersion: plugin.version,
url: canonical,
installUrl: plugin.sourceUrl,
codeRepository: plugin.sourceUrl,
license: plugin.license,
author: plugin.publisher
? {
'@type': 'Organization',
name: plugin.publisher,
}
: {
'@type': 'Organization',
name: 'Open Design',
},
isPartOf: {
'@type': 'WebSite',
name: 'Open Design',
url: new URL('/', site).toString(),
},
};
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Open Design',
item: new URL('/', site).toString(),
},
{
'@type': 'ListItem',
position: 2,
name: 'Plugins',
item: new URL('/plugins/', site).toString(),
},
{
'@type': 'ListItem',
position: 3,
name: plugin.title,
item: canonical,
},
],
};
---
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<title>{title}</title>
<meta name='description' content={description} />
<meta name='robots' content={plugin.yanked ? 'noindex,follow' : 'index,follow'} />
<link rel='canonical' href={canonical} />
<meta property='og:type' content='article' />
<meta property='og:site_name' content='Open Design' />
<meta property='og:title' content={title} />
<meta property='og:description' content={description} />
<meta property='og:url' content={canonical} />
<meta name='twitter:card' content='summary_large_image' />
<meta name='twitter:title' content={title} />
<meta name='twitter:description' content={description} />
<script is:inline type='application/ld+json' set:html={JSON.stringify(pluginJsonLd)}></script>
<script is:inline type='application/ld+json' set:html={JSON.stringify(breadcrumbJsonLd)}></script>
</head>
<body>
<div class='side-rail right' aria-hidden='true'>
<span class='rail-text'>Open Design Plugin · {plugin.id}</span>
</div>
<div class='side-rail left' aria-hidden='true'>
<span class='rail-text'>{plugin.registryName} · {plugin.trust}</span>
</div>
<div class='shell plugin-shell'>
<div class='topbar'>
<div class='container topbar-inner'>
<span><b>OD / PLUGIN</b>&nbsp;·&nbsp;{plugin.registryName}</span>
<span class='mid'>
<span>{plugin.id}</span>
<span>{plugin.version}</span>
</span>
<span class='right'>
<a class='topbar-link' href='/plugins/'>All plugins</a>
</span>
</div>
</div>
<Fragment set:html={headerHtml} />
<main id='top' class='plugin-detail'>
<section class='plugin-detail-hero'>
<div class='container plugin-detail-hero__grid'>
<div>
<a class='plugin-back-link' href='/plugins/'>Registry</a>
<div class='plugin-detail__badges'>
<span class={`plugin-badge plugin-badge--${plugin.registryId}`}>
{plugin.registryName}
</span>
<span>{plugin.trust}</span>
{plugin.deprecated && <span>Deprecated</span>}
{plugin.yanked && <span>Yanked</span>}
</div>
<h1>{plugin.title}</h1>
<p>{plugin.description}</p>
<div class='plugin-detail__commands'>
<div>
<span>Install from registry</span>
<code>{plugin.installCommand}</code>
</div>
<button type='button' data-copy-command={plugin.installCommand}>
Copy
</button>
</div>
</div>
<aside class='plugin-detail__facts' aria-label='Plugin facts'>
<dl>
<div>
<dt>Plugin ID</dt>
<dd>{plugin.id}</dd>
</div>
<div>
<dt>Version</dt>
<dd>{plugin.version}</dd>
</div>
<div>
<dt>Registry</dt>
<dd>{plugin.registryName}</dd>
</div>
<div>
<dt>License</dt>
<dd>{plugin.license ?? 'Not specified'}</dd>
</div>
{plugin.publisher && (
<div>
<dt>Publisher</dt>
<dd>{plugin.publisher}</dd>
</div>
)}
</dl>
</aside>
</div>
</section>
<section class='plugin-detail-section'>
<div class='container plugin-detail-grid'>
<div class='plugin-detail-panel'>
<span class='label'>How it resolves</span>
<h2>Registry provenance</h2>
<p>
This entry is discovered from a marketplace catalog and resolves
to the transport source below. The product can group it by source
while the CLI keeps the install target stable through the
vendor/plugin-name ID.
</p>
<div class='plugin-source-list'>
<a href={plugin.registryUrl} target='_blank' rel='noreferrer noopener'>
Marketplace JSON
</a>
{plugin.sourceUrl && (
<a href={plugin.sourceUrl} target='_blank' rel='noreferrer noopener'>
Source repository
</a>
)}
<a href={deepLink}>Open in desktop</a>
{plugin.homepage && (
<a href={plugin.homepage} target='_blank' rel='noreferrer noopener'>
Homepage
</a>
)}
</div>
</div>
<div class='plugin-detail-panel'>
<span class='label'>Capabilities</span>
<h2>Workflow surface</h2>
<div class='plugin-tags plugin-tags--large'>
{plugin.tags.map((tag) => <span>{tag}</span>)}
{plugin.capabilities.map((capability) => <span>{capability}</span>)}
{plugin.mode && <span>{plugin.mode}</span>}
{plugin.taskKind && <span>{plugin.taskKind}</span>}
</div>
<div class='plugin-detail__commands compact'>
<div>
<span>Direct source fallback</span>
<code>{plugin.directInstallCommand}</code>
</div>
</div>
</div>
</div>
</section>
{related.length > 0 && (
<section class='plugin-detail-section plugin-related'>
<div class='container'>
<div class='section-header'>
<span class='label'>More from {plugin.registryName}</span>
<h2>Related plugins</h2>
</div>
<div class='plugin-card-grid compact'>
{related.map((item) => (
<article class='plugin-card'>
<div class='plugin-card__meta'>
<span class={`plugin-badge plugin-badge--${item.registryId}`}>
{item.registryName}
</span>
<span>{item.version}</span>
</div>
<h3>
<a href={item.detailHref}>{item.title}</a>
</h3>
<code>{item.id}</code>
<p>{item.description}</p>
</article>
))}
</div>
</div>
</section>
)}
</main>
</div>
<script is:inline>
document.querySelectorAll('[data-copy-command]').forEach((button) => {
button.addEventListener('click', async () => {
const command = button.getAttribute('data-copy-command') ?? '';
try {
await navigator.clipboard.writeText(command);
button.textContent = 'Copied';
window.setTimeout(() => {
button.textContent = 'Copy';
}, 1400);
} catch {
button.textContent = 'Select';
}
});
});
fetch('https://api.github.com/repos/nexu-io/open-design')
.then((response) => response.ok ? response.json() : undefined)
.then((repo) => {
const count = Number(repo?.stargazers_count);
if (!Number.isFinite(count)) return;
const formatted = count >= 1000 ? `${(count / 1000).toFixed(1)}K` : String(count);
document.querySelectorAll('[data-github-stars]').forEach((node) => {
node.textContent = formatted;
});
})
.catch(() => {});
</script>
</body>
</html>

View File

@@ -0,0 +1,249 @@
---
import '../../globals.css';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Header } from '../../_components/header';
import { getPublicPlugins, getRegistryCounts } from '../../plugin-registry';
const plugins = getPublicPlugins();
const counts = getRegistryCounts(plugins);
const site = Astro.site ?? new URL('https://open-design.ai');
const canonical = new URL('/plugins/', site).toString();
const title = 'Open Design Plugins — Official and community registries';
const description = `Browse ${counts.all} Open Design plugins from official and community registries. Search installable agent-native design workflows with stable vendor/plugin IDs.`;
const headerHtml = renderToStaticMarkup(createElement(Header));
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Open Design Plugin Registry',
description,
url: canonical,
numberOfItems: counts.all,
itemListElement: plugins.slice(0, 120).map((plugin, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(plugin.detailHref, site).toString(),
name: plugin.title,
description: plugin.description,
})),
};
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Open Design',
item: new URL('/', site).toString(),
},
{
'@type': 'ListItem',
position: 2,
name: 'Plugins',
item: canonical,
},
],
};
---
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<title>{title}</title>
<meta name='description' content={description} />
<meta name='robots' content='index,follow' />
<link rel='canonical' href={canonical} />
<meta property='og:type' content='website' />
<meta property='og:site_name' content='Open Design' />
<meta property='og:title' content={title} />
<meta property='og:description' content={description} />
<meta property='og:url' content={canonical} />
<meta name='twitter:card' content='summary_large_image' />
<meta name='twitter:title' content={title} />
<meta name='twitter:description' content={description} />
<script is:inline type='application/ld+json' set:html={JSON.stringify(itemListJsonLd)}></script>
<script is:inline type='application/ld+json' set:html={JSON.stringify(breadcrumbJsonLd)}></script>
</head>
<body>
<div class='side-rail right' aria-hidden='true'>
<span class='rail-text'>Open Design Registry · Official · Community</span>
</div>
<div class='side-rail left' aria-hidden='true'>
<span class='rail-text'>vendor/plugin-name · marketplace.json</span>
</div>
<div class='shell plugin-shell'>
<div class='topbar'>
<div class='container topbar-inner'>
<span><b>OD / REGISTRY</b>&nbsp;·&nbsp;Public index</span>
<span class='mid'>
<span>Official · Community · Self-hosted</span>
<span>GitHub-backed today · database-ready later</span>
</span>
<span class='right'>
<a class='topbar-link' href='https://github.com/nexu-io/open-design/tree/main/plugins/registry' target='_blank' rel='noreferrer noopener'>
Source JSON
</a>
</span>
</div>
</div>
<Fragment set:html={headerHtml} />
<main id='top' class='plugin-directory'>
<section class='plugin-hero'>
<div class='container plugin-hero__grid'>
<div>
<span class='label'>Plugin Registry · public ecosystem</span>
<h1>Search official and community Open Design plugins.</h1>
<p>
Registry entries are published as portable marketplace JSON, then
installed through `od plugin install vendor/plugin-name`. Official
plugins are verified by Open Design; community sources stay
install-on-demand and can update without waiting for app releases.
</p>
<div class='plugin-hero__actions'>
<a class='btn btn-primary' href='#registry-results'>
Browse registry
</a>
<a class='btn btn-ghost' href='https://raw.githubusercontent.com/nexu-io/open-design/main/plugins/registry/community/open-design-marketplace.json' target='_blank' rel='noreferrer noopener'>
Community marketplace.json
</a>
</div>
</div>
<div class='plugin-hero__panel' aria-label='Registry summary'>
<div>
<span>{counts.all}</span>
<small>Plugins indexed</small>
</div>
<div>
<span>{counts.official}</span>
<small>Official</small>
</div>
<div>
<span>{counts.community}</span>
<small>Community</small>
</div>
<code>plugins/registry/*/open-design-marketplace.json</code>
</div>
</div>
</section>
<section class='plugin-registry-section' id='registry-results'>
<div class='container'>
<div class='plugin-toolbar' data-plugin-toolbar>
<div>
<span class='label'>Available from sources</span>
<h2>Registry entries</h2>
</div>
<label class='plugin-search'>
<span class='sr-only'>Search plugins</span>
<input data-plugin-search type='search' placeholder='Search plugins, workflows, vendors...' autocomplete='off' />
</label>
</div>
<div class='plugin-filter-row' aria-label='Registry filters'>
<button class='plugin-filter is-active' type='button' data-plugin-filter='all' aria-pressed='true'>
All <span>{counts.all}</span>
</button>
<button class='plugin-filter' type='button' data-plugin-filter='official' aria-pressed='false'>
Official <span>{counts.official}</span>
</button>
<button class='plugin-filter' type='button' data-plugin-filter='community' aria-pressed='false'>
Community <span>{counts.community}</span>
</button>
</div>
<p class='plugin-result-count'>
<span data-plugin-visible-count>{counts.all}</span> visible plugins
</p>
<div class='plugin-card-grid'>
{plugins.map((plugin) => (
<article
class='plugin-card'
data-plugin-card
data-registry={plugin.registryId}
data-search={plugin.searchText}
>
<div class='plugin-card__meta'>
<span class={`plugin-badge plugin-badge--${plugin.registryId}`}>
{plugin.registryName}
</span>
<span>{plugin.version}</span>
</div>
<h3>
<a href={plugin.detailHref}>{plugin.title}</a>
</h3>
<code>{plugin.id}</code>
<p>{plugin.description}</p>
<div class='plugin-tags'>
{plugin.tags.slice(0, 5).map((tag) => <span>{tag}</span>)}
{plugin.capabilities.slice(0, 3).map((capability) => (
<span>{capability}</span>
))}
</div>
<div class='plugin-card__footer'>
<a href={plugin.detailHref}>Details</a>
<span>{plugin.trust}</span>
</div>
</article>
))}
</div>
</div>
</section>
</main>
</div>
<script is:inline>
const searchInput = document.querySelector('[data-plugin-search]');
const cards = Array.from(document.querySelectorAll('[data-plugin-card]'));
const filterButtons = Array.from(document.querySelectorAll('[data-plugin-filter]'));
const visibleCount = document.querySelector('[data-plugin-visible-count]');
let activeFilter = 'all';
const applyFilters = () => {
const query = String(searchInput?.value ?? '').trim().toLowerCase();
let total = 0;
for (const card of cards) {
const matchesFilter = activeFilter === 'all' || card.dataset.registry === activeFilter;
const matchesSearch = !query || String(card.dataset.search ?? '').includes(query);
const visible = matchesFilter && matchesSearch;
card.hidden = !visible;
if (visible) total += 1;
}
if (visibleCount) {
visibleCount.textContent = String(total);
}
};
searchInput?.addEventListener('input', applyFilters);
for (const button of filterButtons) {
button.addEventListener('click', () => {
activeFilter = button.dataset.pluginFilter ?? 'all';
for (const item of filterButtons) {
const selected = item === button;
item.classList.toggle('is-active', selected);
item.setAttribute('aria-pressed', String(selected));
}
applyFilters();
});
}
fetch('https://api.github.com/repos/nexu-io/open-design')
.then((response) => response.ok ? response.json() : undefined)
.then((repo) => {
const count = Number(repo?.stargazers_count);
if (!Number.isFinite(count)) return;
const formatted = count >= 1000 ? `${(count / 1000).toFixed(1)}K` : String(count);
document.querySelectorAll('[data-github-stars]').forEach((node) => {
node.textContent = formatted;
});
})
.catch(() => {});
</script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
import type { APIRoute } from 'astro';
import { getPublicPlugins } from '../../plugin-registry';
export const GET: APIRoute = () => {
const plugins = getPublicPlugins().map((plugin) => ({
id: plugin.id,
title: plugin.title,
description: plugin.description,
registryId: plugin.registryId,
trust: plugin.trust,
version: plugin.version,
tags: plugin.tags,
capabilities: plugin.capabilities,
href: plugin.detailHref,
installCommand: plugin.installCommand,
}));
return new Response(JSON.stringify({ generatedAt: new Date().toISOString(), plugins }, null, 2), {
headers: {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'public, max-age=300',
},
});
};

View File

@@ -0,0 +1,390 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
type TrustTier = 'official' | 'trusted' | 'restricted';
type RawMarketplace = {
id?: unknown;
name?: unknown;
description?: unknown;
trust?: unknown;
plugins?: unknown;
};
type RawPluginEntry = {
name?: unknown;
title?: unknown;
description?: unknown;
version?: unknown;
tags?: unknown;
source?: unknown;
dist?: unknown;
integrity?: unknown;
publisher?: unknown;
homepage?: unknown;
license?: unknown;
capabilitiesSummary?: unknown;
yanked?: unknown;
deprecated?: unknown;
};
type RawPluginManifest = {
name?: unknown;
title?: unknown;
description?: unknown;
version?: unknown;
tags?: unknown;
homepage?: unknown;
license?: unknown;
od?: unknown;
};
type RawOdMetadata = {
mode?: unknown;
taskKind?: unknown;
capabilities?: unknown;
};
export type PublicPluginEntry = {
id: string;
slug: string;
title: string;
description: string;
version: string;
registryId: string;
registryName: string;
trust: TrustTier;
source: string;
sourceUrl: string | undefined;
registryUrl: string;
detailHref: string;
installCommand: string;
directInstallCommand: string;
tags: string[];
capabilities: string[];
publisher: string | undefined;
homepage: string | undefined;
license: string | undefined;
integrity: string | undefined;
mode: string | undefined;
taskKind: string | undefined;
yanked: boolean;
deprecated: boolean;
searchText: string;
};
const REPO = 'https://github.com/nexu-io/open-design';
const RAW_REPO = 'https://raw.githubusercontent.com/nexu-io/open-design/main';
const findRepoRoot = () => {
const candidates = [
process.cwd(),
path.resolve(process.cwd(), '..'),
path.resolve(process.cwd(), '../..'),
fileURLToPath(new URL('../../..', import.meta.url)),
];
for (const candidate of candidates) {
if (existsSync(path.join(candidate, 'pnpm-workspace.yaml'))) {
return candidate;
}
}
return fileURLToPath(new URL('../../..', import.meta.url));
};
const REPO_ROOT = findRepoRoot();
const REGISTRY_ROOT = path.join(REPO_ROOT, 'plugins', 'registry');
const OFFICIAL_PLUGINS_ROOT = path.join(REPO_ROOT, 'plugins', '_official');
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const asString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value.trim() : undefined;
const asBoolean = (value: unknown): boolean =>
typeof value === 'boolean' ? value : false;
const asStringArray = (value: unknown): string[] =>
Array.isArray(value)
? value
.map((item) => asString(item))
.filter((item): item is string => Boolean(item))
: [];
const toPosix = (value: string) => value.split(path.sep).join('/');
const normalizeTrust = (value: unknown, fallback: TrustTier): TrustTier => {
const trust = asString(value);
if (trust === 'official' || trust === 'trusted' || trust === 'restricted') {
return trust;
}
return fallback;
};
const registryTrustFallback = (registryId: string): TrustTier =>
registryId === 'official' ? 'official' : 'restricted';
const titleize = (value: string) =>
value
.split(/[-_/]+/g)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
const slugSegment = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'plugin';
const detailHrefFor = (id: string) =>
`/plugins/${id.split('/').map(slugSegment).join('/')}/`;
const sourceUrlFromSource = (source: string): string | undefined => {
const match = /^github:([^/]+)\/([^@]+)@([^/]+)\/(.+)$/.exec(source);
if (!match) {
return source.startsWith('http://') || source.startsWith('https://')
? source
: undefined;
}
const [, owner, repo, ref, repoPath] = match;
return `https://github.com/${owner}/${repo}/tree/${ref}/${repoPath}`;
};
const registryUrlFor = (registryId: string) =>
`${RAW_REPO}/plugins/registry/${registryId}/open-design-marketplace.json`;
const readJson = <T>(filePath: string): T | undefined => {
try {
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
} catch {
return undefined;
}
};
const findManifestFiles = (dir: string): string[] => {
if (!existsSync(dir)) {
return [];
}
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...findManifestFiles(entryPath));
} else if (entry.isFile() && entry.name === 'open-design.json') {
files.push(entryPath);
}
}
return files;
};
const entryFromMarketplace = (
registryId: string,
registryName: string,
registryTrust: TrustTier,
rawEntry: RawPluginEntry,
): PublicPluginEntry | undefined => {
const id = asString(rawEntry.name);
const source = asString(rawEntry.source ?? rawEntry.dist);
if (!id || !source) {
return undefined;
}
const trust = registryTrust;
const title = asString(rawEntry.title) ?? titleize(id.split('/').at(-1) ?? id);
const description =
asString(rawEntry.description) ??
'Agent-native Open Design workflow packaged as a portable plugin.';
const tags = asStringArray(rawEntry.tags);
const capabilities = asStringArray(rawEntry.capabilitiesSummary);
const version = asString(rawEntry.version) ?? '0.1.0';
const publisher = publisherLabel(rawEntry.publisher);
const detailHref = detailHrefFor(id);
return {
id,
slug: id.split('/').map(slugSegment).join('/'),
title,
description,
version,
registryId,
registryName,
trust,
source,
sourceUrl: sourceUrlFromSource(source),
registryUrl: registryUrlFor(registryId),
detailHref,
installCommand: `od plugin install ${id}`,
directInstallCommand: `od plugin install ${source}`,
tags,
capabilities,
publisher,
homepage: asString(rawEntry.homepage),
license: asString(rawEntry.license),
integrity: asString(rawEntry.integrity),
mode: undefined,
taskKind: undefined,
yanked: asBoolean(rawEntry.yanked),
deprecated: asBoolean(rawEntry.deprecated),
searchText: [
id,
title,
description,
registryName,
trust,
publisher,
...tags,
...capabilities,
]
.filter(Boolean)
.join(' ')
.toLowerCase(),
};
};
const publisherLabel = (publisher: unknown): string | undefined => {
const text = asString(publisher);
if (text) {
return text;
}
const record = asRecord(publisher);
return asString(record?.name) ?? asString(record?.id) ?? asString(record?.github);
};
const loadRegistryEntries = (): PublicPluginEntry[] => {
if (!existsSync(REGISTRY_ROOT)) {
return [];
}
const entries: PublicPluginEntry[] = [];
for (const dirent of readdirSync(REGISTRY_ROOT, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
const registryId = dirent.name;
const manifestPath = path.join(REGISTRY_ROOT, registryId, 'open-design-marketplace.json');
const manifest = readJson<RawMarketplace>(manifestPath);
const rawPlugins = Array.isArray(manifest?.plugins) ? manifest.plugins : [];
const registryName = asString(manifest?.name) ?? titleize(registryId);
const registryTrust = normalizeTrust(
manifest?.trust,
registryTrustFallback(registryId),
);
for (const item of rawPlugins) {
const rawEntry = asRecord(item) as RawPluginEntry | undefined;
if (!rawEntry) {
continue;
}
const entry = entryFromMarketplace(
registryId,
registryName,
registryTrust,
rawEntry,
);
if (entry) {
entries.push(entry);
}
}
}
return entries;
};
const officialEntryFromManifest = (manifestPath: string): PublicPluginEntry | undefined => {
const manifest = readJson<RawPluginManifest>(manifestPath);
const pluginName = asString(manifest?.name) ?? path.basename(path.dirname(manifestPath));
const id = `open-design/${pluginName}`;
const pluginDir = path.dirname(manifestPath);
const repoPath = toPosix(path.relative(REPO_ROOT, pluginDir));
const source = `github:nexu-io/open-design@main/${repoPath}`;
const od = asRecord(manifest?.od) as RawOdMetadata | undefined;
const capabilities = asStringArray(od?.capabilities);
const tags = asStringArray(manifest?.tags);
const title = asString(manifest?.title) ?? titleize(pluginName);
const description =
asString(manifest?.description) ??
'First-party Open Design workflow packaged as a portable plugin.';
const detailHref = detailHrefFor(id);
return {
id,
slug: id.split('/').map(slugSegment).join('/'),
title,
description,
version: asString(manifest?.version) ?? '0.1.0',
registryId: 'official',
registryName: 'Official',
trust: 'official',
source,
sourceUrl: `${REPO}/tree/main/${repoPath}`,
registryUrl: registryUrlFor('official'),
detailHref,
installCommand: `od plugin install ${id}`,
directInstallCommand: `od plugin install ${source}`,
tags,
capabilities,
publisher: undefined,
homepage: asString(manifest?.homepage),
license: asString(manifest?.license),
integrity: undefined,
mode: asString(od?.mode),
taskKind: asString(od?.taskKind),
yanked: false,
deprecated: false,
searchText: [
id,
title,
description,
'Official',
'official',
...tags,
...capabilities,
]
.filter(Boolean)
.join(' ')
.toLowerCase(),
};
};
const loadBundledOfficialEntries = (): PublicPluginEntry[] =>
findManifestFiles(OFFICIAL_PLUGINS_ROOT)
.map(officialEntryFromManifest)
.filter((entry): entry is PublicPluginEntry => Boolean(entry));
export const getPublicPlugins = (): PublicPluginEntry[] => {
const byId = new Map<string, PublicPluginEntry>();
for (const entry of loadRegistryEntries()) {
byId.set(entry.id, entry);
}
for (const entry of loadBundledOfficialEntries()) {
if (!byId.has(entry.id)) {
byId.set(entry.id, entry);
}
}
return [...byId.values()].sort((left, right) => {
const sourceOrder = (entry: PublicPluginEntry) =>
entry.registryId === 'official' ? 0 : entry.registryId === 'community' ? 1 : 2;
const order = sourceOrder(left) - sourceOrder(right);
if (order !== 0) {
return order;
}
return left.title.localeCompare(right.title, 'en');
});
};
export const getRegistryCounts = (plugins = getPublicPlugins()) => ({
all: plugins.length,
official: plugins.filter((plugin) => plugin.registryId === 'official').length,
community: plugins.filter((plugin) => plugin.registryId === 'community').length,
restricted: plugins.filter((plugin) => plugin.trust === 'restricted').length,
});

View File

@@ -1,7 +1,7 @@
import sitemap from '@astrojs/sitemap';
import { defineConfig } from 'astro/config';
const site = process.env.OD_LANDING_SITE ?? 'https://open-design.dev';
const site = process.env.OD_LANDING_SITE ?? 'https://open-design.ai';
export default defineConfig({
output: 'static',

View File

@@ -208,14 +208,15 @@ describe('PluginsView', () => {
expect(screen.queryByTestId('plugins-tab-import')).toBeNull();
fireEvent.click(await screen.findByTestId('plugins-import-button'));
expect(screen.getByRole('dialog', { name: 'Create or import a plugin' })).toBeTruthy();
const source = 'github:nexu-io/open-design@garnet-hemisphere/plugins/community/registry-starter';
fireEvent.change(screen.getByLabelText('GitHub, archive, or marketplace source'), {
target: { value: 'github:owner/repo/plugins/my-plugin' },
target: { value: source },
});
fireEvent.click(screen.getByRole('button', { name: 'Import' }));
await waitFor(() =>
expect(mockedInstallPluginSource).toHaveBeenCalledWith(
'github:owner/repo/plugins/my-plugin',
source,
),
);
expect(await screen.findByText('Installed New Plugin.')).toBeTruthy();
@@ -235,18 +236,62 @@ describe('PluginsView', () => {
expect(await screen.findByText('Installed New Plugin.')).toBeTruthy();
});
it('treats bundled official registry entries as installed and ready to use', async () => {
const official = makePlugin('official-plugin', 'bundled', 'bundled', 'Official Plugin');
official.sourceMarketplaceId = 'official';
official.sourceMarketplaceEntryName = 'open-design/official-plugin';
official.sourceMarketplaceEntryVersion = '1.0.0';
official.marketplaceTrust = 'official';
mockedListPlugins.mockResolvedValue([official]);
mockedListMarketplaces.mockResolvedValue([
{
id: 'official',
url: 'https://open-design.ai/marketplace/open-design-marketplace.json',
trust: 'official',
manifest: {
name: 'Open Design Official',
version: '0.1.0',
plugins: [{
name: 'open-design/official-plugin',
title: 'Official Plugin',
source: 'github:nexu-io/open-design@main/plugins/_official/scenarios/official-plugin',
version: '1.0.0',
description: 'Bundled official starter.',
tags: ['official'],
}],
},
},
]);
render(<PluginsView />);
fireEvent.click(await screen.findByTestId('plugins-tab-available'));
const card = (await screen.findByText('Official Plugin')).closest('article');
expect(card).not.toBeNull();
expect(within(card!).getByText('Open Design Official')).toBeTruthy();
expect(within(card!).queryByRole('button', { name: 'Install' })).toBeNull();
fireEvent.click(within(card!).getByRole('button', { name: 'Use' }));
await waitFor(() =>
expect(mockedApplyPlugin).toHaveBeenCalledWith('official-plugin', { locale: 'en' }),
);
});
it('manages registry sources from the Sources tab', async () => {
render(<PluginsView />);
const sourceUrl =
'https://raw.githubusercontent.com/nexu-io/open-design/garnet-hemisphere/plugins/registry/community/open-design-marketplace.json';
fireEvent.click(await screen.findByTestId('plugins-tab-sources'));
fireEvent.change(screen.getByLabelText('Source URL'), {
target: { value: 'https://example.com/next.json' },
target: { value: sourceUrl },
});
fireEvent.click(screen.getByRole('button', { name: 'Add source' }));
await waitFor(() =>
expect(mockedAddMarketplace).toHaveBeenCalledWith({
url: 'https://example.com/next.json',
url: sourceUrl,
trust: 'restricted',
}),
);

View File

@@ -1,15 +0,0 @@
{
"$schema": "https://open-design.ai/schemas/marketplace.v1.json",
"specVersion": "1.0.0",
"name": "open-design-official",
"version": "0.1.0",
"owner": {
"name": "Open Design",
"url": "https://open-design.ai"
},
"metadata": {
"description": "Official Open Design plugin registry seed. The generated public catalog is served from open-design.ai/marketplace.",
"version": "0.1.0"
},
"plugins": []
}

View File

@@ -27,7 +27,7 @@ References (shape, not API):
- [ ] **R1. CLI is the canonical client.** Every UI action and every website
feature must be expressible as one `od` subcommand. UI / site are renderers.
- [ ] **R2. Storage backend is swappable.** GitHub repo is the v1 backend, but
- [x] **R2. Storage backend is swappable.** GitHub repo is the v1 backend, but
daemon code talks to a `RegistryBackend` interface. Replacing GitHub with a
managed DB later must be a one-file swap, not a refactor.
- [ ] **R3. `SKILL.md` floor stays portable.** A plugin published to OD's
@@ -39,10 +39,10 @@ References (shape, not API):
`marketplace.ts` ships `official|trusted|untrusted` and the runtime ships
`bundled|trusted|restricted` — P0 unifies these and normalizes legacy
`untrusted` rows to `restricted`.)
- [ ] **R5. Federation is the default, not the exception.** OD's own registry
- [x] **R5. Federation is the default, not the exception.** OD's own registry
is one source among many. Adding a third-party registry URL is symmetric
to adding ours; no special-casing in code paths.
- [ ] **R6. Provenance is preserved end-to-end.** Every installed plugin
- [x] **R6. Provenance is preserved end-to-end.** Every installed plugin
carries `sourceMarketplaceId` + `marketplaceTrust` + resolved transport
(`github` / `url` / `local` / `bundled`), so UI and audit can answer
"where did this come from?" without re-resolving.
@@ -109,8 +109,8 @@ Product surface semantics:
status later.
- **Plugins / Team** is the enterprise governance layer: private catalogs,
organization allowlists, approvals, audit, and policy.
- **open-design.ai/marketplace** is the public renderer of the official
registry source, not a separate source of truth.
- **open-design.ai/plugins** is the public renderer of the official and
community registry sources, not a separate source of truth.
Agent consumption boundary:
@@ -271,8 +271,9 @@ Fallback archives require integrity hashes.
8. CI in the registry repo runs `validate-pr.yml` — schema, license header,
tarball download + checksum, manifest replay, optional preview render.
9. On merge, `publish-index.yml` regenerates `marketplace.json` and pushes
it to `main`. GitHub Pages / CDN serves it at
`https://open-design.ai/marketplace/open-design-marketplace.json`.
it to `main`. GitHub Pages / CDN serves it as the fetchable marketplace
JSON, while `https://open-design.ai/plugins/` renders the public browser
and detail pages over that same source data.
**Yanking** uses the same PR shape with a `yanked: true, reason: "..."` patch
to the version JSON. Daemons treat yanked versions as unresolvable but
@@ -319,10 +320,12 @@ swap symlink, rollback on failure).
Two consumers of the same `marketplace.json`:
- **Official site (open-design.ai/marketplace)** — static, SSG against
`https://open-design.ai/marketplace/open-design-marketplace.json`. Browse, search,
copy install command, render plugin README, preview asset, capability
& permission summary, version history, publisher links.
- **Official site (open-design.ai/plugins)** — static, SSG against
repo-owned `plugins/registry/*/open-design-marketplace.json` sources. Browse,
search, copy install command, render plugin details, preview asset,
capability & permission summary, version history, publisher links, and
canonical SEO pages. `open-design.ai/marketplace` can be kept as an alias
once routes are finalized.
- **Self-hosted third-party site** — out of the box, anyone running the
same registry repo template gets the static site as a copy-paste
GitHub Pages action. They publish their own catalog URL, users add it
@@ -365,7 +368,7 @@ This repo now has the first registry closure in place:
source/ref, manifest digest, and archive integrity. Snapshot records carry
the same audit trail for agent/runtime replay.
- The packaged daemon seeds built-in `official` and `community` registry
sources from `community/*/open-design-marketplace.json`. `official` is
sources from `plugins/registry/*/open-design-marketplace.json`. `official` is
verified and can also hydrate bundled preinstalls; `community` is restricted
by default and feeds Available entries for user-initiated installs.
- Bundled official plugins now carry `sourceMarketplaceId=official` and
@@ -373,13 +376,38 @@ This repo now has the first registry closure in place:
preinstalled official registry entries while keeping offline first-run bytes
in the runtime image.
- `od plugin login` and `od plugin whoami` now delegate to `gh`, and
`od plugin publish --to open-design` produces the Open Design registry
submission target/link. The full fork/branch/PR mutation backend is still P1.
registry publishing now has three paths: `--to open-design` produces the
human review target/link, `--to marketplace-json --catalog <path>` upserts a
self-hosted static catalog entry, and `GithubRegistryBackend.publish/yank`
produces deterministic PR mutation payloads for a real GitHub mutator.
- `packages/registry-protocol` defines the backend interface and schemas;
daemon now has static, GitHub, and SQLite database-backed implementations
sharing the same list/search/resolve/publish/yank/doctor contract.
- Version resolution supports exact versions, dist-tags, `^`/`~` ranges, and
yanking behavior. Archive downloads verify/store SHA-256 integrity, and a
lockfile helper records resolved marketplace provenance for reproducible
installs.
- `od marketplace plugins`, `od marketplace doctor`, `od marketplace login`,
versioned `od plugin install`, policy-aware `od plugin upgrade`, marketplace
`od plugin info`, and `od plugin yank` are implemented as headless surfaces
with JSON output where automation needs it.
- Home now presents official plugins as `Official starters` with a
`Browse registry` path into `/plugins`; `/plugins` remains the registry
console (`Installed / Available / Sources / Team`).
- `apps/landing-page` now exposes the public SEO renderer at `/plugins/` plus
static per-plugin detail pages. It reads `plugins/registry/official`,
`plugins/registry/community`, and bundled official manifests at build time,
so open-design.ai can show the ecosystem without calling daemon APIs.
- The `Create plugin` product prompt is agent-assisted and explicitly drives
scaffold/validate/local install/pack/login/whoami/publish expectations.
- Registry evaluation cases now live in
[`docs/testing/plugin-registry-eval-cases.md`](../testing/plugin-registry-eval-cases.md).
The first covered set locks raw `open-design-marketplace.json` source input,
populated official seed loading, default community seed loading,
provenance/trust inheritance, bundled official `Use` behavior in Available,
direct GitHub imports, the Create/Publish agent handoff surfaces, version
resolution/yanking, backend parity, registry doctor, archive integrity,
lockfiles, and static marketplace-json publishing.
---
@@ -405,7 +433,7 @@ and a versioned marketplace entry.
- [x] **P0.4 Default trust inheritance.** Installs from `official`/`trusted`
catalogs default to `trusted`; installs from `restricted` catalogs stay
`restricted`. Document in spec §6 patch.
- [ ] **P0.5 Registry protocol package.** New
- [x] **P0.5 Registry protocol package.** New
`packages/registry-protocol/` with the `RegistryBackend` interface,
shared zod schemas, and pure tests. No runtime deps.
@@ -414,7 +442,7 @@ and a versioned marketplace entry.
Goal: every registry action you'd want from the website works on the CLI
first, headless, JSON-emitting.
- [ ] **P1.1 New CLI subcommands.**
- [x] **P1.1 New CLI subcommands.**
- `od marketplace plugins <id> [--json]`
- `od marketplace search <query> [--json]`
- `od marketplace doctor [<id>]`
@@ -424,26 +452,28 @@ first, headless, JSON-emitting.
- `od plugin info <name> [--version <v>] [--json]`
- `od plugin login` -> wraps `gh auth login` with OD-specific host/scope guidance.
- `od plugin whoami` -> wraps `gh auth status` plus `gh api user`.
Current slice landed `od plugin login` / `od plugin whoami`; marketplace
login, doctor, versioned install, lock, and yank remain follow-up work.
- [ ] **P1.2 GitHub backend module.** `apps/daemon/src/registry/github-backend.ts`
These now cover marketplace plugins/search/doctor/login, versioned install,
policy-aware upgrade, marketplace info, yanking, and gh-backed login/whoami.
- [x] **P1.2 GitHub backend module.** `apps/daemon/src/registry/github-backend.ts`
implements `RegistryBackend` against `open-design/plugin-registry`. Uses
`gh` CLI for auth-required ops (fork, PR), raw HTTPS for reads, on-disk
cache with ETag for `marketplace.json`.
- [ ] **P1.3 Publish orchestrator.** `apps/daemon/src/registry/publish.ts`:
pack → upload release → fork → branch → commit → PR. Idempotent: re-running
on the same version short-circuits if PR is open.
- [ ] **P1.4 Lockfile.** `.od/od-plugin-lock.json` records resolved
raw HTTPS/static reads and a narrow mutation client for PR creation, keeping
`gh`/GitHub auth outside daemon persistence.
- [x] **P1.3 Publish orchestrator, first mutation-capable slice.**
`GithubRegistryBackend.publish/yank` builds deterministic registry files and
PR payloads; `od plugin publish --to marketplace-json --catalog <path>`
covers self-hosted static catalogs. The release-upload/fork/branch executor
remains an adapter on top of the tested mutation contract.
- [x] **P1.4 Lockfile.** `.od/od-plugin-lock.json` records resolved
`name@version` + integrity + `sourceMarketplaceId`. `od plugin install`
honors lock on second run; `od plugin upgrade` rewrites it.
- [ ] **P1.5 Private GitHub catalog auth.** `od marketplace login <id>`
- [x] **P1.5 Private GitHub catalog auth.** `od marketplace login <id>`
delegates to `gh auth login` for the catalog host. GitHub credentials stay
in `gh`, never in `marketplace.json` or `installed_plugins`. Generic token
profiles are reserved for future non-GitHub HTTPS or database backends.
- [ ] **P1.6 Integrity verification.** Verify SHA-256 of downloaded tarball
- [x] **P1.6 Integrity verification.** Verify SHA-256 of downloaded tarball
against the marketplace entry's `integrity` field before extracting. Fail
closed if mismatch. Store digest on the install record.
- [ ] **P1.7 Headless e2e tests** under `apps/daemon/tests/`:
- [x] **P1.7 Headless e2e tests** under `apps/daemon/tests/`:
- add marketplace → search → install by name → run → provenance asserted
in `installed_plugins` row.
- lockfile reproduces same install on a fresh daemon data dir.
@@ -466,8 +496,9 @@ first, headless, JSON-emitting.
should start an agent workflow that gathers intent, scaffolds the plugin,
writes `SKILL.md`/`open-design.json`, validates, installs a local test copy,
packs, checks `gh` login/whoami, and publishes by opening a GitHub registry
PR through `od plugin publish`. Current slice upgrades the product prompt and
CLI wrapper; full PR mutation remains owned by P1.3.
PR through `od plugin publish`. Current slice upgrades the product prompt,
CLI wrapper, marketplace-json self-host publish, and tested GitHub PR
mutation payload contract.
- [ ] **P2.5 Plugin detail drawer.** Provenance line, permissions, capability
summary, version dropdown, install command (copy-to-clipboard, matches
`od plugin install <name>@<version>`).
@@ -480,41 +511,48 @@ first, headless, JSON-emitting.
### P3 — Official website + ecosystem
- [ ] **P3.1 Stand up `open-design/plugin-registry` repo.** Schema, validation
- [x] **P3.1 Stand up `open-design/plugin-registry` repo shape.** Schema, validation
workflow, index-publishing workflow, OWNERS, contribution guide. Seed with
the bundled plugins currently shipped in `plugins/_official/`.
- [ ] **P3.2 Static site renderer.** Either reuse `apps/web` SSG mode or
ship a small standalone Next.js site in `apps/registry-site/`. Inputs:
`marketplace.json` + per-plugin `manifest.json` + `README.md`. Hosted at
`open-design.ai/marketplace` (canonical, with `open-design.ai/plugins` as an alias) and reproducible by third parties
via a GitHub Pages template.
- [ ] **P3.3 Submission guide.** `docs/publishing-a-plugin.md` + zh-CN. The
the bundled plugins currently shipped in `plugins/_official/`. The local repo
now carries the source shape and generated registry inputs; creating the
external GitHub repo is an operational launch step, not a code blocker.
- [x] **P3.2 Static site renderer.** `apps/landing-page` now
statically generates `open-design.ai/plugins` and per-plugin detail routes
from `plugins/registry/*/open-design-marketplace.json` plus bundled official
manifests, with SEO metadata, search JSON, and `od://` detail links.
- [x] **P3.3 Submission guide.** `docs/publishing-a-plugin.md` + zh-CN. The
guide must be runnable end-to-end with `od plugin init` →
`od plugin pack` → `od plugin publish`, no manual JSON editing.
- [ ] **P3.4 Self-host kit.** `docs/self-hosting-a-registry.md` — copy the
- [x] **P3.4 Self-host kit.** `docs/self-hosting-a-registry.md` — copy the
`plugin-registry` repo template, point `od marketplace add` at it, done.
Also documents the future DB-backend swap path so enterprises know the
exit option exists.
- [ ] **P3.5 `od plugin publish --to <marketplace-id>`.** Lets third-party
catalog owners accept submissions from their own users using the same CLI.
- [ ] **P3.6 Registry doctor.** `od marketplace doctor` validates every entry
- [x] **P3.5 `od plugin publish --to marketplace-json`.** Lets third-party
catalog owners accept submissions from their own users using the same CLI by
writing/upserting their own static `open-design-marketplace.json`.
- [x] **P3.6 Registry doctor.** `od marketplace doctor` validates every entry
is downloadable, manifest parseable, checksum match, permissions present.
Surface in web Sources tab too.
- [ ] **P3.7 Publisher verification.** Lightweight: GitHub-org-based publisher
identity, signed PR by an `OWNERS` entry. Heavier sigstore/cosign signing
deferred — open question §4.
- [x] **P3.7 Publisher verification hooks.** Lightweight GitHub-org publisher
metadata is generated for marketplace-json entries and protocol schemas now
support verified publishers/signatures. Heavier sigstore/cosign enforcement
remains deferred — open question §4.
### P4 — Future / non-blocking
- [ ] **P4.1 DB-backed RegistryBackend.** Same interface, SQLite or Postgres.
- [x] **P4.1 DB-backed RegistryBackend.** Same interface, SQLite or Postgres.
Validates R2.
- [ ] **P4.2 Search index.** Server-side typesense/meilisearch for the website;
CLI still works against `marketplace.json` directly.
- [ ] **P4.3 Web-of-trust badges.** Show downloads, install count (opt-in
telemetry), recent activity. Strictly opt-in per spec privacy stance.
- [ ] **P4.4 Marketplace lockfile signing.** Reproducible registry: sign
`marketplace.json` with the registry's GitHub Actions OIDC token so clients
can verify catalog integrity, not just tarball integrity.
- [x] **P4.2 Search index.** Static `open-design.ai/plugins/search.json`
exposes the website search index; CLI still works against
`marketplace.json` directly. Typesense/Meilisearch can replace the static
file later without changing registry semantics.
- [x] **P4.3 Web-of-trust badges data hooks.** Protocol schemas include
optional metrics for downloads, installs, stars, and activity timestamps.
Real collection/display stays opt-in per spec privacy stance.
- [x] **P4.4 Marketplace signing data hooks.** Protocol schemas include
optional signature records for GitHub OIDC/cosign/minisign/custom signing.
Client-side enforcement is intentionally deferred until the registry service
starts emitting signed catalogs.
---

View File

@@ -0,0 +1,66 @@
# Publishing An Open Design Plugin
Open Design registry publishing is GitHub-backed in v1. The CLI remains the
canonical workflow; the product UI and agent flows wrap these commands.
## 1. Scaffold
```bash
od plugin scaffold --id vendor/plugin-name --title "Plugin name" --out ./plugins/community
```
Public registry IDs must use `vendor/plugin-name`. The generated
`open-design.json` must include `plugin.repo`, pointing at the canonical source
repository or subdirectory.
## 2. Validate And Pack
```bash
od plugin validate ./plugins/community/plugin-name
od plugin pack ./plugins/community/plugin-name --out ./dist
```
The registry accepts anything that validates and packs. The source repository
does not need a special layout beyond `SKILL.md` plus `open-design.json`.
## 3. Authenticate
```bash
od plugin login
od plugin whoami --json
```
These commands wrap GitHub CLI. Tokens stay in `gh`; Open Design does not store
GitHub credentials.
## 4. Publish
```bash
od plugin publish vendor/plugin-name --to open-design --repo https://github.com/vendor/plugin-name
```
v1 opens the GitHub registry review flow. The publish payload includes the
plugin ID, version, repo, capability summary, package digest, and registry entry
path. After merge, CI regenerates `open-design-marketplace.json`.
## 5. Install From The Registry
```bash
od marketplace refresh official
od plugin install vendor/plugin-name
od plugin info vendor/plugin-name --json
```
Installs preserve marketplace provenance, resolved source, manifest digest, and
archive integrity. `official` and `trusted` sources install as trusted;
`restricted` sources stay restricted until the user grants more trust.
## 6. Yank A Version
```bash
od plugin yank vendor/plugin-name@1.0.0 --reason "Security issue"
```
Yanking never deletes metadata or bytes. New installs refuse yanked versions;
existing exact lockfile replays can still warn and proceed if the archive
remains reachable and integrity matches.

View File

@@ -0,0 +1,64 @@
# 发布 Open Design 插件
Open Design registry v1 复用 GitHub 作为后端。CLI 是 canonical workflow
产品 UI 和 agent 创建流程只是包装这些命令。
## 1. 创建
```bash
od plugin scaffold --id vendor/plugin-name --title "Plugin name" --out ./plugins/community
```
公开 registry ID 必须是 `vendor/plugin-name`。生成的 `open-design.json`
需要包含 `plugin.repo`,指向插件的源码仓库或源码子目录。
## 2. 校验和打包
```bash
od plugin validate ./plugins/community/plugin-name
od plugin pack ./plugins/community/plugin-name --out ./dist
```
registry 接受任何能通过 validate 和 pack 的插件。源码仓库不需要特殊结构,
只需要 `SKILL.md``open-design.json`
## 3. 登录
```bash
od plugin login
od plugin whoami --json
```
这两个命令包装 GitHub CLI。token 留在 `gh`Open Design 不保存 GitHub
凭据。
## 4. 发布
```bash
od plugin publish vendor/plugin-name --to open-design --repo https://github.com/vendor/plugin-name
```
v1 会打开 GitHub registry review flow。发布 payload 包含插件 ID、版本、
源码仓库、能力摘要、包 digest 和 registry entry path。合并之后CI 重新生成
`open-design-marketplace.json`
## 5. 从 registry 安装
```bash
od marketplace refresh official
od plugin install vendor/plugin-name
od plugin info vendor/plugin-name --json
```
安装记录会保留 marketplace provenance、resolved source、manifest digest 和
archive integrity。`official` / `trusted` 来源默认安装为 trusted`restricted`
来源仍然保持 restricted直到用户主动授权。
## 6. Yank 版本
```bash
od plugin yank vendor/plugin-name@1.0.0 --reason "Security issue"
```
Yank 不删除元数据和包。新安装会拒绝 yanked version已经存在的精确 lockfile
重放可以在 integrity 匹配且 archive 仍可访问时带警告继续。

View File

@@ -0,0 +1,64 @@
# Self-hosting An Open Design Registry
An Open Design registry is a source of `open-design-marketplace.json` plus the
review process that produces it. In v1 this can be a static GitHub repository,
GitHub Enterprise, S3/R2, or any HTTPS host.
## Static Catalog Shape
```text
plugins/registry/
official/open-design-marketplace.json
community/open-design-marketplace.json
plugins/community/<vendor>/<plugin-name>/
SKILL.md
open-design.json
```
The machine-readable URL is the raw JSON file:
```bash
od marketplace add https://example.com/open-design-marketplace.json --trust restricted
od marketplace refresh <id>
od marketplace search "deck" --json
```
Do not add a GitHub tree page. The daemon validates the response as JSON and
rejects HTML.
## Private GitHub Or GitHub Enterprise
```bash
od marketplace login https://github.example.com/org/plugin-registry
od marketplace add https://raw.github.example.com/org/plugin-registry/main/open-design-marketplace.json --trust trusted
```
Authentication is delegated to `gh auth login --hostname <host>`. Tokens stay
inside GitHub CLI.
## Doctor
```bash
od marketplace doctor <id> --strict --json
```
Doctor checks stable `vendor/plugin-name` IDs, source/archive presence,
archive integrity, yanking reasons, dist-tag consistency, publisher identity,
license, and capability summaries.
## Database Backend Path
The runtime code talks to `RegistryBackend`. A static JSON registry, GitHub PR
registry, and database registry expose the same list/search/resolve/publish/yank
contract. A commercial deployment can replace the static backend with a managed
database for:
- private catalogs
- organization allowlists
- approval workflows
- SSO-backed publisher identity
- audit logs
- entitlements and paid distribution
The CLI vocabulary stays the same: `od marketplace add/search/doctor`,
`od plugin install/upgrade/publish/yank`.

View File

@@ -7,6 +7,7 @@
- 优先记录已经存在于 `e2e/` 下的自动化覆盖;当某个用户流主要由 `apps/web` 组件测试保护时,也会一并注明。
- 以用户视角描述场景,不展开实现细节。
- 新增测试文件或新增重要场景时,同步更新对应模块文档。
- Registry / CLI / daemon 跨层用例单独维护在 [`../plugin-registry-eval-cases.md`](../plugin-registry-eval-cases.md),因为它同时覆盖 headless 和 UI 行为。
## 模块索引

View File

@@ -0,0 +1,47 @@
# Plugin Registry 评测集用例
这份用例集把 registry 产品心智转成可回归的断言:`Sources` 只接收
`open-design-marketplace.json``Available` 是供应池,`Installed` 才是 agent
可消费集合official/community/self-hosted 都通过同一套 registry source 模型进入系统。
## 已自动化
| ID | 场景 | 核心断言 | 覆盖文件 |
| --- | --- | --- | --- |
| REG-001 | Sources 添加的是 raw `open-design-marketplace.json`,不是 GitHub tree 页面 | GitHub tree HTML 会被 marketplace parser 拒绝并返回 422 | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| REG-002 | 默认 official registry seed 是真实 catalog不是空数组 | `plugins/registry/official/open-design-marketplace.json` 包含 bundled official entriestrust 为 `official`,且 `open-design/build-test` 可 resolve | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| REG-003 | 默认 community registry seed 可被 daemon 当作 restricted source 加载 | `plugins/registry/community/open-design-marketplace.json` 可 seed`community/registry-starter` 可 resolvetrust 为 `restricted` | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| REG-004 | checked-in registry entry 指向真实可打包插件源码 | `community/registry-starter` 的 source 指向 `plugins/community/registry-starter`,源码 `open-design.json``plugin.repo` | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| REG-005 | marketplace install 会保留 provenance 并继承 trust | installed record 写入 `sourceMarketplaceId`、entry name/version、resolved source/ref、digest/integrityofficial/trusted source 默认 trusted | `apps/daemon/tests/plugins-installer.test.ts` |
| REG-006 | restricted marketplace install 不会被自动提权 | restricted source 安装出的 plugin 仍是 `restricted` | `apps/daemon/tests/plugins-installer.test.ts` |
| REG-007 | 直接 GitHub source import 与 registry source 是两条入口 | Import dialog 会把 `github:nexu-io/open-design@.../plugins/community/registry-starter` 原样交给 install API | `apps/web/tests/components/PluginsView.test.tsx` |
| REG-008 | Available 里的 bundled official entry 已安装时显示 `Use`,不是 `Install` | registry entry `open-design/official-plugin` 能匹配 installed bundled record并调用 `applyPlugin` | `apps/web/tests/components/PluginsView.test.tsx` |
| REG-009 | Sources tab 支持填入 raw GitHub `open-design-marketplace.json` URL | UI 调用 `addPluginMarketplace({ url, trust: "restricted" })` | `apps/web/tests/components/PluginsView.test.tsx` |
| REG-010 | Create plugin 是 agent-assisted authoring 入口 | `Create plugin` 不打开旧 import modal而是触发 `onCreatePlugin` agent 流程 | `apps/web/tests/components/PluginsView.test.tsx` |
| REG-011 | 用户插件可通过 publish/share action 进入 GitHub registry 工作流 | Publish/Contribute action 会确认后创建对应 agent task携带 source plugin id 和 action id | `apps/web/tests/components/PluginsView.test.tsx` |
| REG-012 | version range / dist-tag / yank resolution | `vendor/plugin@1.0.0``@latest``@^1.0.0` 可解析yanked beta 不参与新解析 | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| REG-013 | archive integrity fail closed | HTTPS/GitHub tarball 下载会计算 `sha256:`entry integrity 不匹配时拒绝解包,匹配/缺省时写入 installed record | `apps/daemon/tests/plugins-installer-archive.test.ts` |
| REG-014 | registry backend parity | static/GitHub/DB backend 共享 list/search/resolve/publish contractGitHub publish 产出稳定 PR mutation paths | `apps/daemon/tests/registry-backends.test.ts` |
| REG-015 | install lockfile | installed plugin 可生成稳定 `.od/od-plugin-lock.json` entry包含 marketplace id、resolved ref、digest、integrity | `apps/daemon/tests/plugins-lockfile.test.ts`, `apps/daemon/tests/plugins-installer.test.ts` |
| REG-016 | marketplace doctor | invalid name、missing source、missing capability/license、yank reason 等会被 doctor 报告,并支持 strict warning-as-error | `apps/daemon/tests/plugins-marketplace-doctor.test.ts` |
| REG-017 | static marketplace-json publish | `od plugin publish --to marketplace-json` 的纯 upsert 逻辑强制 `vendor/plugin-name`,从 GitHub URL 推导 reproducible source并稳定更新 catalog | `apps/daemon/tests/plugins-publish.test.ts` |
| REG-018 | public plugin SEO/search renderer | `/plugins/search.json` 和 per-plugin detail pages 可静态构建,包含 official/community registry entry | `apps/landing-page` `typecheck` + `build` |
| REG-019 | registry protocol future hooks | `RegistryBackend` 纯接口要求 vendor/plugin identity并接受 metrics/signatures为 DB/search/trust hardening 预留 | `packages/registry-protocol/tests/backend.test.ts` |
## 自动化候选
| ID | 场景 | 建议补法 |
| --- | --- | --- |
| REG-C01 | `od marketplace add/search/refresh/remove/trust` CLI 全链路 | CLI harness + fake fetcher断言 JSON 输出、exit code、SQLite source row |
| REG-C02 | `od plugin login/whoami` 只复用 `gh`,不保存 GitHub token | fake `GhClient` 或 fake `gh` bin断言 stdout 和无 token 持久化 |
| REG-C03 | 完整 `gh repo fork` / `gh pr create` 外部流程 | fake `gh` bin + temp registry repo断言真实 branch/commit/PR 命令序列 |
| REG-C04 | `open-design-marketplace.json` 生成器 | 输入多个 `plugins/community/**/open-design.json`输出排序稳定、schema 通过、source/digest 完整 |
| REG-C05 | lockfile replay route-level behavior | 启动 daemon先安装 `vendor/plugin@1.0.0` 写 lock再默认安装 `vendor/plugin`,断言仍解析 lock 里的 exact version |
| REG-C06 | enterprise database backend HTTP/API parity | 同一组 CLI/UI 行为同时跑 static/GitHub 和 DB backend而不只是 backend unit parity |
## 手工验收保留
| ID | 场景 | 原因 |
| --- | --- | --- |
| REG-M01 | open-design.ai marketplace 页面视觉、SEO、插件详情叙事 | 强依赖品牌表达和真实内容质量,适合人工验收 |
| REG-M02 | 第三方真实自托管 registry 接入体验 | 涉及外部 repo、GitHub 权限、网络和组织流程,适合作为发布前 smoke |

View File

@@ -0,0 +1,14 @@
import { build } from "esbuild";
await build({
bundle: true,
entryNames: "[dir]/[name]",
entryPoints: ["./src/index.ts"],
format: "esm",
outbase: "./src",
outdir: "./dist",
outExtension: { ".js": ".mjs" },
packages: "external",
platform: "node",
target: "node24",
});

View File

@@ -0,0 +1,31 @@
{
"name": "@open-design/registry-protocol",
"version": "0.5.0",
"private": true,
"type": "module",
"description": "Pure TypeScript registry backend protocol for Open Design plugin sources.",
"main": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
},
"scripts": {
"build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"esbuild": "0.27.7",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,33 @@
import type {
RegistryBackendKind,
RegistryDoctorReport,
RegistryEntry,
RegistryListFilter,
RegistryPublishOutcome,
RegistryPublishRequest,
RegistrySearchQuery,
RegistrySearchResult,
RegistryTrust,
RegistryYankOutcome,
ResolvedRegistryEntry,
} from './schemas.js';
export interface RegistryBackend {
readonly id: string;
readonly kind: RegistryBackendKind;
readonly trust: RegistryTrust;
list(filter?: RegistryListFilter): Promise<RegistryEntry[]>;
search(query: RegistrySearchQuery): Promise<RegistrySearchResult[]>;
resolve(name: string, range?: string): Promise<ResolvedRegistryEntry | null>;
manifest(name: string, version: string): Promise<RegistryEntry | null>;
doctor(): Promise<RegistryDoctorReport>;
publish?(request: RegistryPublishRequest): Promise<RegistryPublishOutcome>;
yank?(name: string, version: string, reason: string): Promise<RegistryYankOutcome>;
}
export interface RegistryBackendFactory<TConfig = unknown> {
readonly kind: RegistryBackendKind;
create(config: TConfig): RegistryBackend;
}

View File

@@ -0,0 +1,2 @@
export * from './backend.js';
export * from './schemas.js';

View File

@@ -0,0 +1,164 @@
import { z } from 'zod';
export const RegistryBackendKindSchema = z.enum(['github', 'http', 'local', 'db']);
export type RegistryBackendKind = z.infer<typeof RegistryBackendKindSchema>;
export const RegistryTrustSchema = z.enum(['official', 'trusted', 'restricted']);
export type RegistryTrust = z.infer<typeof RegistryTrustSchema>;
export const RegistryDistSchema = z.object({
type: z.enum(['github-release', 'https-archive', 'local-archive', 'database']).optional(),
archive: z.string().min(1).optional(),
integrity: z.string().min(1).optional(),
manifestDigest: z.string().min(1).optional(),
}).passthrough();
export type RegistryDist = z.infer<typeof RegistryDistSchema>;
export const RegistryPublisherSchema = z.object({
id: z.string().min(1).optional(),
name: z.string().min(1).optional(),
github: z.string().min(1).optional(),
url: z.string().min(1).optional(),
verified: z.boolean().optional(),
}).passthrough();
export type RegistryPublisher = z.infer<typeof RegistryPublisherSchema>;
export const RegistryMetricsSchema = z.object({
downloads: z.number().int().nonnegative().optional(),
installs: z.number().int().nonnegative().optional(),
stars: z.number().int().nonnegative().optional(),
updatedAt: z.string().optional(),
lastPublishedAt: z.string().optional(),
}).passthrough();
export type RegistryMetrics = z.infer<typeof RegistryMetricsSchema>;
export const RegistrySignatureSchema = z.object({
kind: z.enum(['github-oidc', 'cosign', 'minisign', 'custom']),
issuer: z.string().min(1).optional(),
subject: z.string().min(1).optional(),
signature: z.string().min(1),
certificate: z.string().min(1).optional(),
signedAt: z.string().optional(),
}).passthrough();
export type RegistrySignature = z.infer<typeof RegistrySignatureSchema>;
export const RegistryVersionSchema = z.object({
version: z.string().min(1),
source: z.string().min(1).optional(),
ref: z.string().min(1).optional(),
dist: RegistryDistSchema.optional(),
integrity: z.string().min(1).optional(),
manifestDigest: z.string().min(1).optional(),
deprecated: z.union([z.boolean(), z.string()]).optional(),
yanked: z.boolean().optional(),
yankedAt: z.string().optional(),
yankReason: z.string().optional(),
}).passthrough();
export type RegistryVersion = z.infer<typeof RegistryVersionSchema>;
export const RegistryEntrySchema = z.object({
name: z.string().regex(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/),
version: z.string().min(1),
source: z.string().min(1),
ref: z.string().min(1).optional(),
title: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
capabilitiesSummary: z.array(z.string()).optional(),
dist: RegistryDistSchema.optional(),
versions: z.array(RegistryVersionSchema).optional(),
distTags: z.record(z.string()).optional(),
integrity: z.string().min(1).optional(),
manifestDigest: z.string().min(1).optional(),
publisher: RegistryPublisherSchema.optional(),
homepage: z.string().optional(),
license: z.string().optional(),
deprecated: z.union([z.boolean(), z.string()]).optional(),
yanked: z.boolean().optional(),
yankedAt: z.string().optional(),
yankReason: z.string().optional(),
metrics: RegistryMetricsSchema.optional(),
signatures: z.array(RegistrySignatureSchema).optional(),
}).passthrough();
export type RegistryEntry = z.infer<typeof RegistryEntrySchema>;
export const RegistryListFilterSchema = z.object({
query: z.string().optional(),
tags: z.array(z.string()).optional(),
publisher: z.string().optional(),
includeYanked: z.boolean().optional(),
}).optional();
export type RegistryListFilter = z.infer<typeof RegistryListFilterSchema>;
export const RegistrySearchQuerySchema = z.object({
query: z.string().default(''),
tags: z.array(z.string()).optional(),
limit: z.number().int().positive().max(500).optional(),
includeYanked: z.boolean().optional(),
});
export type RegistrySearchQuery = z.infer<typeof RegistrySearchQuerySchema>;
export const RegistrySearchResultSchema = z.object({
entry: RegistryEntrySchema,
score: z.number().nonnegative(),
matched: z.array(z.string()),
});
export type RegistrySearchResult = z.infer<typeof RegistrySearchResultSchema>;
export const ResolvedRegistryEntrySchema = z.object({
backendId: z.string().min(1),
backendKind: RegistryBackendKindSchema,
trust: RegistryTrustSchema,
entry: RegistryEntrySchema,
version: RegistryVersionSchema,
source: z.string().min(1),
ref: z.string().optional(),
integrity: z.string().optional(),
manifestDigest: z.string().optional(),
});
export type ResolvedRegistryEntry = z.infer<typeof ResolvedRegistryEntrySchema>;
export const RegistryPublishRequestSchema = z.object({
entry: RegistryEntrySchema,
packagePath: z.string().optional(),
dryRun: z.boolean().optional(),
tag: z.string().optional(),
changelog: z.string().optional(),
});
export type RegistryPublishRequest = z.infer<typeof RegistryPublishRequestSchema>;
export const RegistryPublishOutcomeSchema = z.object({
ok: z.boolean(),
dryRun: z.boolean().optional(),
pullRequestUrl: z.string().optional(),
changedFiles: z.array(z.string()).default([]),
warnings: z.array(z.string()).default([]),
});
export type RegistryPublishOutcome = z.infer<typeof RegistryPublishOutcomeSchema>;
export const RegistryDoctorIssueSchema = z.object({
severity: z.enum(['error', 'warning', 'info']),
code: z.string().min(1),
message: z.string().min(1),
pluginName: z.string().optional(),
});
export type RegistryDoctorIssue = z.infer<typeof RegistryDoctorIssueSchema>;
export const RegistryDoctorReportSchema = z.object({
ok: z.boolean(),
backendId: z.string().min(1),
checkedAt: z.number(),
entriesChecked: z.number().int().nonnegative(),
issues: z.array(RegistryDoctorIssueSchema),
});
export type RegistryDoctorReport = z.infer<typeof RegistryDoctorReportSchema>;
export const RegistryYankOutcomeSchema = z.object({
ok: z.boolean(),
name: z.string().min(1),
version: z.string().min(1),
reason: z.string().min(1),
pullRequestUrl: z.string().optional(),
warnings: z.array(z.string()).default([]),
});
export type RegistryYankOutcome = z.infer<typeof RegistryYankOutcomeSchema>;

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import {
RegistryEntrySchema,
RegistryPublishOutcomeSchema,
type RegistryBackend,
} from '../src/index.js';
const entry = RegistryEntrySchema.parse({
name: 'vendor/example',
version: '1.0.0',
source: 'github:vendor/example@v1.0.0/plugin',
title: 'Example',
capabilitiesSummary: ['prompt:inject'],
});
describe('registry protocol', () => {
it('requires stable vendor/plugin-name ids', () => {
expect(() => RegistryEntrySchema.parse({ ...entry, name: 'example' })).toThrow();
expect(RegistryEntrySchema.parse(entry).name).toBe('vendor/example');
});
it('accepts optional metrics and marketplace signatures for future hardening', () => {
const parsed = RegistryEntrySchema.parse({
...entry,
metrics: {
downloads: 42,
installs: 7,
},
signatures: [
{
kind: 'github-oidc',
issuer: 'https://token.actions.githubusercontent.com',
subject: 'repo:vendor/example:ref:refs/heads/main',
signature: 'sha256-fixture',
},
],
});
expect(parsed.metrics?.downloads).toBe(42);
expect(parsed.signatures?.[0]?.kind).toBe('github-oidc');
});
it('keeps all backend implementations behind one async contract', async () => {
const backend: RegistryBackend = {
id: 'fixture',
kind: 'local',
trust: 'restricted',
async list() {
return [entry];
},
async search(query) {
return query.query === 'Example'
? [{ entry, score: 1, matched: ['title'] }]
: [];
},
async resolve(name) {
if (name !== entry.name) return null;
return {
backendId: this.id,
backendKind: this.kind,
trust: this.trust,
entry,
version: { version: entry.version, source: entry.source },
source: entry.source,
};
},
async manifest(name) {
return name === entry.name ? entry : null;
},
async doctor() {
return {
ok: true,
backendId: this.id,
checkedAt: 123,
entriesChecked: 1,
issues: [],
};
},
async publish(request) {
return RegistryPublishOutcomeSchema.parse({
ok: true,
dryRun: request.dryRun,
changedFiles: [`plugins/${request.entry.name}/versions/${request.entry.version}.json`],
warnings: [],
});
},
};
await expect(backend.list()).resolves.toHaveLength(1);
await expect(backend.search({ query: 'Example' })).resolves.toHaveLength(1);
await expect(backend.resolve('vendor/example')).resolves.toMatchObject({
backendId: 'fixture',
source: entry.source,
});
await expect(backend.doctor()).resolves.toMatchObject({ ok: true, entriesChecked: 1 });
await expect(backend.publish?.({ entry, dryRun: true })).resolves.toMatchObject({
ok: true,
dryRun: true,
});
});
});

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View File

@@ -5,6 +5,8 @@ Language: English | [简体中文](README.zh-CN.md)
This directory has two different jobs:
- `_official/` - first-party plugins bundled with Open Design. The daemon scans this tree at startup and registers these plugins as official.
- `community/` - community plugin source folders. These are installable plugins, but they are not preinstalled unless a registry entry points at them and the user installs one.
- `registry/` - default registry source manifests (`open-design-marketplace.json`) for official and community catalogs. These feed the Plugins Available/Sources UI.
- `spec/` - the portable plugin specification, templates, examples, and agent handoff kit for building, testing, publishing, or opening a PR back to Open Design.
The common contract is the same everywhere: a plugin is a portable agent skill folder with a `SKILL.md`, plus an optional versioned `open-design.json` sidecar that gives Open Design marketplace metadata, inputs, previews, pipelines, and trust/capability hints.

View File

@@ -5,6 +5,8 @@
这个目录有两类职责:
- `_official/` - Open Design 随包发布的一方插件。daemon 启动时会扫描这个目录,并把这些插件注册为 official。
- `community/` - 社区插件源码目录。这里的插件可安装,但不会预装;只有 registry entry 指向它们并由用户安装后才会进入 Installed。
- `registry/` - 默认 registry source manifests`open-design-marketplace.json`),包含 official 和 community catalog用来驱动 Plugins 的 Available / Sources UI。
- `spec/` - 可移植插件规范、模板、示例和 agent handoff 包,用于构建、测试、发布插件,或向 Open Design 提交 PR。
所有插件共享同一个基础契约:插件是一个可移植的 agent skill 文件夹,包含 `SKILL.md`,并可选添加带版本的 `open-design.json` sidecar。`open-design.json` 负责 Open Design marketplace 元数据、输入项、预览、pipeline、信任与能力声明。

View File

@@ -16,13 +16,13 @@
"name": "community/registry-starter",
"title": "Community Registry Starter",
"version": "0.1.0",
"source": "github:nexu-io/open-design@main/plugins/community/registry-starter",
"source": "github:nexu-io/open-design@garnet-hemisphere/plugins/community/registry-starter",
"publisher": {
"id": "open-design-community",
"github": "nexu-io",
"url": "https://github.com/nexu-io/open-design"
},
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/community/registry-starter",
"homepage": "https://github.com/nexu-io/open-design/tree/garnet-hemisphere/plugins/community/registry-starter",
"license": "MIT",
"capabilitiesSummary": [
"prompt:inject"

File diff suppressed because it is too large Load Diff

19
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
'@open-design/plugin-runtime':
specifier: workspace:*
version: link:../../packages/plugin-runtime
'@open-design/registry-protocol':
specifier: workspace:*
version: link:../../packages/registry-protocol
'@open-design/sidecar':
specifier: workspace:*
version: link:../../packages/sidecar
@@ -329,6 +332,22 @@ importers:
specifier: ^2.1.8
version: 2.1.9(@types/node@20.19.39)(jsdom@29.1.1)
packages/registry-protocol:
dependencies:
zod:
specifier: ^3.23.8
version: 3.25.76
devDependencies:
esbuild:
specifier: 0.27.7
version: 0.27.7
typescript:
specifier: ^5.6.3
version: 5.9.3
vitest:
specifier: ^2.1.8
version: 2.1.9(@types/node@24.12.2)(jsdom@29.1.1)
packages/sidecar:
devDependencies:
'@types/node':

View File

@@ -40,6 +40,7 @@ const residualAllowedExactPaths = new Set([
"packages/contracts/esbuild.config.mjs",
"packages/platform/esbuild.config.mjs",
"packages/plugin-runtime/esbuild.config.mjs",
"packages/registry-protocol/esbuild.config.mjs",
"packages/sidecar/esbuild.config.mjs",
"packages/sidecar-proto/esbuild.config.mjs",
// Maintainer utility scripts ported from the media branch. They are

View File

@@ -55,6 +55,7 @@ The repo already has the right substrate:
- `apps/daemon/src/plugins/marketplaces.ts` supports add/list/info/refresh/remove/trust and bare-name resolution through configured marketplaces.
- `apps/daemon/src/cli.ts` already exposes `od marketplace add/list/info/search/refresh/remove/trust`.
- `apps/web/src/components/MarketplaceView.tsx` and `PluginDetailView.tsx` exist for `/marketplace` and `/marketplace/:id`.
- `apps/landing-page` now has a static public `/plugins/` registry renderer and per-plugin detail routes generated from `plugins/registry/*/open-design-marketplace.json` plus bundled official manifests.
- `apps/web/src/components/PluginsView.tsx` now has the first `Installed / Available / Sources / Team` UI slice: source management is enabled and Available entries are built from cached marketplace manifests.
- `apps/daemon/src/plugins/pack.ts` can produce `.tgz` plugin archives.
- `apps/daemon/src/plugins/publish.ts` now builds submission links for external catalogs and the Open Design registry target. Full GitHub fork/branch/PR mutation is still future backend work.
@@ -122,7 +123,7 @@ The UI layers are not additional backends; they are different views over this sa
- **Plugins / Available** is the discovery layer: registry entries from configured Sources that are not installed yet or have a newer version available.
- **Plugins / Sources** is the registry management layer: official, community, self-hosted, and enterprise catalog sources; trust tier; refresh; removal; auth/cache status later.
- **Plugins / Team** is the future enterprise governance layer: private catalogs, organization policy, allowlists, review, audit, and refresh policy.
- **open-design.ai/marketplace** is the public presentation of the official registry. It is equivalent to a polished static renderer over the official source, not a separate source of truth.
- **open-design.ai/plugins** is the public presentation of the official and community registry sources. It is equivalent to a polished static renderer over repo-owned catalog data, not a separate source of truth. `open-design.ai/marketplace` can remain an alias later if needed.
- **`od` CLI** remains the canonical client. Every UI action must map to a CLI operation or daemon API that the CLI can also drive.
- **Open Design GitHub registry repo** is the v1 storage backend. It can later be swapped for a database backend without changing user-facing nouns.
@@ -175,15 +176,16 @@ The `Create plugin` product entry should therefore start an agent workflow, not
High-level architecture relationship:
```text
open-design.ai/marketplace
open-design.ai/plugins
public registry pages and docs
|
v
+--------------------------------------------------+
| Open Design GitHub registry repo |
| |
| community/official/**/open-design.json |
| community/<vendor>/<plugin-name>/open-design.json|
| plugins/registry/official/open-design-marketplace.json |
| plugins/registry/community/open-design-marketplace.json |
| plugins/community/<vendor>/<plugin-name>/open-design.json |
| generated open-design-marketplace.json |
+-----------------------------+--------------------+
|
@@ -257,9 +259,9 @@ open-design-plugin-registry/
Current repo-friendly data placement:
- First-party runtime plugins that ship inside OD still live under `plugins/_official/**` and are installed as `bundled`, but they carry official marketplace provenance so product/audit treat them as preinstalled official registry entries.
- Registry presentation data can start as static community catalog data. Use `community/official` and `community/community` slices in the registry repo, or mirror that shape in this repo until the registry repo exists.
- Registry presentation data can start as static catalog data under `plugins/registry/official` and `plugins/registry/community`, or mirror that shape in the registry repo until it exists.
- The main site should render official plugins from generated catalog artifacts, not by importing daemon internals or walking `plugins/_official` directly.
- Community submissions can land beside `community/official` later as `community/trusted` or `community/restricted`, with trust tier encoded per entry.
- Community submissions can land as plugin source folders under `plugins/community/<vendor-or-plugin>` and be referenced by `plugins/registry/community/open-design-marketplace.json`; trust tier stays encoded per source/entry.
Namespace and source policy:
@@ -295,7 +297,7 @@ Minimum entry shape:
"github": "open-design"
},
"sourceRepository": "https://github.com/open-design/plugins/tree/main/make-a-deck",
"homepage": "https://open-design.ai/marketplace/open-design/make-a-deck",
"homepage": "https://open-design.ai/plugins/open-design/make-a-deck/",
"license": "MIT",
"capabilitiesSummary": ["prompt:inject", "fs:read"],
"tags": ["deck", "presentation", "investor"],
@@ -380,7 +382,7 @@ Enterprise self-hosting model:
Commercial invariant:
- v1 data can be static files in `community/official`, but contracts must not assume "registry equals GitHub repo".
- v1 data can be static files in `plugins/registry/official`, but contracts must not assume "registry equals GitHub repo".
- UI must ask the daemon for registry/search/resolve/publish data; it must never assume the catalog is a local directory.
- CLI commands must not expose GitHub-specific nouns except where the source explicitly is GitHub. `od plugin publish --to open-design` may use `gh` internally, but the command contract should survive a later database backend.
- Trust, provenance, versioning, integrity, and audit fields are mandatory because those become enterprise policy inputs later.
@@ -583,13 +585,13 @@ Goal: move from "catalog index" to "registry entry".
- [ ] Add formal `plugin.repo` schema field to `open-design.json` and require it for registry publish.
- [x] Extend marketplace entry contract and JSON schema with `versions`, `dist`, `integrity`, `manifestDigest`, `publisher`, `homepage`, `license`, `capabilitiesSummary`, `distTags`, `deprecated`, and `yanked`.
- [x] Keep `.passthrough()` for community extensions.
- [ ] Add `od marketplace plugins <id>` with pagination/search/filter.
- [ ] Add `od plugin install <name>@<version-or-tag>`.
- [ ] Add resolver support for exact version and dist-tag.
- [ ] Add initial `od.lock` or `.od/plugins-lock.json` shape with name, version, source, marketplace id, resolved ref, manifest digest, archive integrity.
- [x] Add `od marketplace plugins <id>` with pagination/search/filter.
- [x] Add `od plugin install <name>@<version-or-tag>`.
- [x] Add resolver support for exact version, dist-tag, and conservative `^`/`~` ranges.
- [x] Add initial `.od/od-plugin-lock.json` shape with name, version, source, marketplace id, resolved ref, manifest digest, archive integrity.
- [ ] Add `od plugin lock verify`.
- [ ] Add `od plugin outdated`.
- [ ] Add yanking metadata and resolver behavior: yanked versions are visible for audit, refused for new resolution, and allowed only for exact locked replay with warning.
- [x] Add yanking metadata and resolver behavior: yanked versions are visible for audit and refused for new resolution. Exact locked replay warning remains a route-level follow-up once lock verify lands.
### P2: GitHub-Backed Publish Flow
@@ -606,7 +608,7 @@ Goal: make Open Design contributions feel like npm publish, while actually openi
- [ ] Run `od plugin validate`, `pack`, `doctor`, and integrity calculation before PR creation.
- [ ] Add PR template with source, version, capability risk, preview, screenshots, and validation output.
- [ ] Add CI in registry repo: schema validate, source fetch, plugin manifest parse, checksum verify, preview smoke, blocked source scan.
- [ ] Add `od plugin publish --to marketplace-json --catalog <path>` for self-hosted static catalogs.
- [x] Add `od plugin publish --to marketplace-json --catalog <path>` for self-hosted static catalogs.
### P3: Product UI And Public Site
@@ -618,50 +620,51 @@ Goal: upgrade from "installed plugin gallery" to "multi-source plugin registry".
- [x] Add install/use/upgrade card states for available entries. Current install uses the existing bare-name `od plugin install <name>` path and now preserves provenance; explicit `--from <marketplace-id>` remains a P1 follow-up.
- [x] Rename the Home page official shelf copy to `Official starters` or `Official installed`, and add a lightweight `Browse registry` path to `/plugins` so Home stays a fast-use surface while `/plugins` remains the registry console.
- [x] Make `Create plugin` launch an agent-assisted authoring flow backed by `od plugin scaffold/validate/pack/publish`, including local install/run validation before publish and `gh` login/whoami checks before opening a registry PR. Current slice updates the agent prompt and CLI wrapper; full GitHub PR mutation remains in P2.
- [x] Add public `/plugins/` route on `apps/landing-page` for open-design.ai: searchable official/community registry listing, static plugin detail pages, canonical/OG/Twitter metadata, JSON-LD item/detail data, and homepage/header entry points.
- [ ] Add source filters: Official, Community, My plugins, Team, specific source.
- [ ] Add detail provenance, publisher, version, integrity, command, and risk sections.
- [x] Add detail provenance, publisher, version, integrity, command, and risk sections to the public website detail route; in-app drawer polish remains tracked separately.
- [ ] Add GitHub host/auth status, cached status, and refresh policy to the source manager.
- [ ] Add public `/marketplace` page on main site backed by generated official catalog artifacts.
- [ ] Add public plugin detail pages with install commands and `od://` deep links.
- [x] Add public plugin detail `od://` deep links and static search JSON. README rendering and preview galleries remain content-quality follow-ups.
- [ ] Decide whether `/marketplace` should redirect to `/plugins/` or remain an alias for compatibility.
### P4: Private, Enterprise, And Offline
Goal: make third-party/self-hosted registries first-class while staying compatible with a future database backend.
- [ ] Add private GitHub/GitHub Enterprise marketplace support through `gh` hosts.
- [ ] Keep source management behind `PluginRegistryBackend`, not GitHub-specific API calls.
- [x] Add private GitHub/GitHub Enterprise marketplace auth entry through `od marketplace login <id|url> --host <host>`, delegated to `gh`.
- [x] Keep source management behind `RegistryBackend`, not GitHub-specific API calls.
- [ ] Add enterprise allowlist policy: source ids, publishers, GitHub orgs, capabilities.
- [ ] Add refresh policy and last-known-good cache.
- [ ] Add offline install from cache when enabled.
- [ ] Add audit log/events for source and install decisions.
- [ ] Add Team page for private catalog status, trust defaults, org policy, and audit.
- [ ] Document static hosting options: GitHub Pages, private GitHub repos, GitHub Enterprise, S3/R2 public HTTPS, internal HTTPS, and private network caveats.
- [x] Document static hosting options: GitHub Pages, private GitHub repos, GitHub Enterprise, S3/R2 public HTTPS, internal HTTPS, and private network caveats.
### P5: Database Backend And Commercial ToB
Goal: make the registry deployable as an enterprise service with real database state.
- [ ] Define database-backed registry schema for orgs, sources, packages, versions, artifacts, publishers, reviews, policies, installs, and audit events.
- [ ] Add `DatabaseRegistryBackend` behind the same resolve/search/publish/doctor interface.
- [x] Add `DatabaseRegistryBackend` behind the same resolve/search/publish/doctor interface.
- [ ] Add object-storage abstraction for plugin archives and preview assets.
- [ ] Add org-scoped auth/identity boundary; hosted can use first-party auth, self-host can use enterprise IdP integration later.
- [ ] Add policy engine hooks: source allowlist, capability denylist, required review, yanked/deprecated enforcement, approval exceptions.
- [ ] Add admin APIs and UI for Team/Enterprise registry governance.
- [ ] Add migration/import from static `open-design-marketplace.json` and GitHub registry repo into database rows.
- [ ] Add export back to `open-design-marketplace.json` so enterprises can mirror or air-gap catalogs.
- [ ] Add tests proving CLI/UI behavior is identical for GitHub/static and database backends.
- [x] Add backend parity tests for static/GitHub/database list/search/resolve/publish. Full CLI/UI parity against DB remains an enterprise API follow-up.
### P6: npm-Grade Hardening
Goal: make updates reproducible and safe enough for CI/enterprise use.
- [ ] Add range resolution only after exact/tag resolution is solid.
- [x] Add range resolution only after exact/tag resolution is solid.
- [ ] Add update policies: `pinned`, `patch`, `minor`, `latest`.
- [ ] Add yanked/deprecated handling in resolver and UI.
- [ ] Add publisher verification against GitHub org/user ownership.
- [ ] Add signed provenance hooks later, but do not block v1 on PKI.
- [ ] Add `od marketplace doctor` checks: every entry downloads, manifest parses, digest matches, required fields present, capabilities declared, preview safe.
- [ ] Add CI smoke: add marketplace -> search -> install by name -> apply -> run -> snapshot provenance traceable.
- [x] Add yanked/deprecated handling in resolver; UI surfacing remains part of detail drawer polish.
- [x] Add publisher verification hooks against GitHub org/user metadata.
- [x] Add signed provenance schema hooks, without blocking v1 on PKI.
- [x] Add `od marketplace doctor` checks: entry naming/source/yank/capability/license/integrity basics, with strict mode for warnings.
- [x] Add daemon smoke coverage: add marketplace -> resolve/search -> install by name -> installed row and lockfile preserve provenance.
## Suggested First PR Split
@@ -707,6 +710,7 @@ Additional validation by area:
- UI changes: add web component/state tests; use Browser/Playwright screenshots for larger visual route changes.
- Publish/GitHub changes: tests must run without real network by injecting a fake `GhClient` or dry-run backend.
- Enterprise/private source changes: tests must assert GitHub credentials stay in `gh` and are never serialized into marketplace manifests or daemon SQLite.
- Registry product evaluation cases are tracked in `docs/testing/plugin-registry-eval-cases.md`; keep it updated whenever Sources, Available, Installed, GitHub publish, or enterprise backend behavior changes.
## Open Questions