mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
257 lines
9 KiB
TypeScript
257 lines
9 KiB
TypeScript
// Plugin registry. Phase 1 scope:
|
|
//
|
|
// - Scans `<daemonDataDir>/plugins/<id>/` (the OD-canonical install root) for
|
|
// manifest folders.
|
|
// - Resolves a plugin folder into either an `open-design.json`-anchored
|
|
// manifest or a synthesized one derived from `SKILL.md` /
|
|
// `.claude-plugin/plugin.json` (per spec §3 compatibility matrix).
|
|
// - Persists discovered records into the `installed_plugins` SQLite row so
|
|
// subsequent CLI / HTTP calls can read without rescanning the FS.
|
|
//
|
|
// Phase 2A will add the project-cwd tier and the legacy SKILL.md tiers; we
|
|
// keep this module narrow today so the loader / installer split stays
|
|
// honest. Adding more tiers is a pure data-source change and never a
|
|
// schema migration.
|
|
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import { promises as fsp } from 'node:fs';
|
|
import {
|
|
adaptAgentSkill,
|
|
adaptClaudePlugin,
|
|
mergeManifests,
|
|
parseManifest,
|
|
validateSafe,
|
|
type ManifestParseResult,
|
|
} from '@open-design/plugin-runtime';
|
|
import type {
|
|
InstalledPluginRecord,
|
|
PluginManifest,
|
|
PluginSourceKind,
|
|
TrustTier,
|
|
} from '@open-design/contracts';
|
|
import type Database from 'better-sqlite3';
|
|
|
|
type SqliteDb = Database.Database;
|
|
type DbRow = Record<string, unknown>;
|
|
|
|
export interface RegistryRoots {
|
|
// User-installed plugin bytes. Production passes a daemon data-root-derived
|
|
// value; tests can point this at a sandbox.
|
|
userPluginsRoot: string;
|
|
}
|
|
|
|
export function registryRootsForDataDir(dataDir: string): RegistryRoots {
|
|
return {
|
|
userPluginsRoot: path.join(dataDir, 'plugins'),
|
|
};
|
|
}
|
|
|
|
export function defaultRegistryRoots(): RegistryRoots {
|
|
return registryRootsForDataDir(path.resolve(process.env.OD_DATA_DIR ?? path.join(process.cwd(), '.od')));
|
|
}
|
|
|
|
export interface ScannedPlugin {
|
|
record: InstalledPluginRecord;
|
|
warnings: string[];
|
|
}
|
|
|
|
export interface ResolveOptions {
|
|
// The on-disk folder. Used for both reading and computing the manifest's
|
|
// sourceDigest. Phase 2A swaps this to the registry's discovered fsPath.
|
|
folder: string;
|
|
folderId: string;
|
|
sourceKind?: PluginSourceKind;
|
|
source?: string;
|
|
pinnedRef?: string;
|
|
trust?: TrustTier;
|
|
capabilitiesGranted?: string[];
|
|
}
|
|
|
|
export interface ResolveOutcome {
|
|
ok: true;
|
|
record: InstalledPluginRecord;
|
|
warnings: string[];
|
|
}
|
|
|
|
export interface ResolveFailure {
|
|
ok: false;
|
|
errors: string[];
|
|
warnings: string[];
|
|
}
|
|
|
|
export type ResolveResult = ResolveOutcome | ResolveFailure;
|
|
|
|
// Resolve a single plugin folder into a typed InstalledPluginRecord. Pure
|
|
// FS read, no SQLite write — the installer module is the only writer.
|
|
export async function resolvePluginFolder(opts: ResolveOptions): Promise<ResolveResult> {
|
|
const warnings: string[] = [];
|
|
const errors: string[] = [];
|
|
const folder = opts.folder;
|
|
|
|
let stats: fs.Stats;
|
|
try {
|
|
stats = await fsp.stat(folder);
|
|
} catch (err) {
|
|
return { ok: false, errors: [`Plugin folder not found: ${folder} (${(err as Error).message})`], warnings };
|
|
}
|
|
if (!stats.isDirectory()) {
|
|
return { ok: false, errors: [`Plugin path is not a directory: ${folder}`], warnings };
|
|
}
|
|
|
|
const sidecarPath = path.join(folder, 'open-design.json');
|
|
const skillPath = path.join(folder, 'SKILL.md');
|
|
const claudePath = path.join(folder, '.claude-plugin', 'plugin.json');
|
|
|
|
let sidecar: PluginManifest | undefined;
|
|
if (fs.existsSync(sidecarPath)) {
|
|
const rawSidecar = await fsp.readFile(sidecarPath, 'utf8');
|
|
const parsed: ManifestParseResult = parseManifest(rawSidecar);
|
|
if (!parsed.ok) {
|
|
errors.push(...parsed.errors.map((e) => `open-design.json: ${e}`));
|
|
} else {
|
|
sidecar = parsed.manifest;
|
|
warnings.push(...parsed.warnings);
|
|
}
|
|
}
|
|
|
|
const adapters: PluginManifest[] = [];
|
|
if (fs.existsSync(skillPath)) {
|
|
const raw = await fsp.readFile(skillPath, 'utf8');
|
|
const adapted = adaptAgentSkill(raw, { folderId: opts.folderId });
|
|
adapters.push(adapted.manifest);
|
|
warnings.push(...adapted.warnings);
|
|
}
|
|
if (fs.existsSync(claudePath)) {
|
|
const raw = await fsp.readFile(claudePath, 'utf8');
|
|
const adapted = adaptClaudePlugin(raw, { folderId: opts.folderId });
|
|
adapters.push(adapted.manifest);
|
|
warnings.push(...adapted.warnings);
|
|
}
|
|
|
|
if (!sidecar && adapters.length === 0) {
|
|
return {
|
|
ok: false,
|
|
errors: [...errors, `Plugin folder contains no SKILL.md, no .claude-plugin/plugin.json, and no open-design.json: ${folder}`],
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
const manifest = mergeManifests({ sidecar, adapters });
|
|
const validation = validateSafe(manifest);
|
|
warnings.push(...validation.warnings);
|
|
if (!validation.ok) {
|
|
return { ok: false, errors: [...errors, ...validation.errors], warnings };
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
return { ok: false, errors, warnings };
|
|
}
|
|
|
|
const now = Date.now();
|
|
// The manifest name wins (spec §5.1: plugin id IS the manifest name). The
|
|
// folderId fallback only kicks in when an adapter-only manifest forgot to
|
|
// set name, which Zod validation already rejects.
|
|
const id = (manifest.name ?? opts.folderId).toLowerCase();
|
|
const record: InstalledPluginRecord = {
|
|
id,
|
|
title: manifest.title ?? manifest.name,
|
|
version: manifest.version,
|
|
sourceKind: opts.sourceKind ?? 'local',
|
|
source: opts.source ?? folder,
|
|
pinnedRef: opts.pinnedRef,
|
|
sourceMarketplaceId: undefined,
|
|
trust: opts.trust ?? 'restricted',
|
|
capabilitiesGranted: opts.capabilitiesGranted ?? defaultRestrictedCapabilities(),
|
|
manifest,
|
|
fsPath: folder,
|
|
installedAt: now,
|
|
updatedAt: now,
|
|
};
|
|
return { ok: true, record, warnings };
|
|
}
|
|
|
|
function defaultRestrictedCapabilities(): string[] {
|
|
// Spec §5.3: restricted plugins start with prompt:inject only. Apply-time
|
|
// grants land additional capabilities on the snapshot, never here.
|
|
return ['prompt:inject'];
|
|
}
|
|
|
|
// Map a SQLite row back into an InstalledPluginRecord. Centralized so every
|
|
// reader gets the same JSON parsing contract.
|
|
export function rowToInstalledPlugin(row: DbRow): InstalledPluginRecord {
|
|
const manifestJson = typeof row['manifest_json'] === 'string' ? (row['manifest_json'] as string) : '{}';
|
|
const manifest = JSON.parse(manifestJson) as PluginManifest;
|
|
const capabilitiesJson = typeof row['capabilities_granted'] === 'string' ? (row['capabilities_granted'] as string) : '[]';
|
|
const capabilities = JSON.parse(capabilitiesJson) as string[];
|
|
return {
|
|
id: String(row['id']),
|
|
title: String(row['title']),
|
|
version: String(row['version']),
|
|
sourceKind: row['source_kind'] as PluginSourceKind,
|
|
source: String(row['source']),
|
|
pinnedRef: row['pinned_ref'] != null ? String(row['pinned_ref']) : undefined,
|
|
sourceDigest: row['source_digest'] != null ? String(row['source_digest']) : undefined,
|
|
sourceMarketplaceId: row['source_marketplace_id'] != null ? String(row['source_marketplace_id']) : undefined,
|
|
trust: row['trust'] as TrustTier,
|
|
capabilitiesGranted: Array.isArray(capabilities) ? capabilities : [],
|
|
manifest,
|
|
fsPath: String(row['fs_path']),
|
|
installedAt: Number(row['installed_at']),
|
|
updatedAt: Number(row['updated_at']),
|
|
};
|
|
}
|
|
|
|
export function listInstalledPlugins(db: SqliteDb): InstalledPluginRecord[] {
|
|
const rows = db.prepare(`SELECT * FROM installed_plugins ORDER BY title ASC`).all() as DbRow[];
|
|
return rows.map(rowToInstalledPlugin);
|
|
}
|
|
|
|
export function getInstalledPlugin(db: SqliteDb, id: string): InstalledPluginRecord | null {
|
|
const row = db.prepare(`SELECT * FROM installed_plugins WHERE id = ?`).get(id) as DbRow | undefined;
|
|
return row ? rowToInstalledPlugin(row) : null;
|
|
}
|
|
|
|
export function upsertInstalledPlugin(db: SqliteDb, record: InstalledPluginRecord): void {
|
|
db.prepare(`
|
|
INSERT INTO installed_plugins (
|
|
id, title, version, source_kind, source, pinned_ref, source_digest,
|
|
source_marketplace_id, trust, capabilities_granted, manifest_json,
|
|
fs_path, installed_at, updated_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
title = excluded.title,
|
|
version = excluded.version,
|
|
source_kind = excluded.source_kind,
|
|
source = excluded.source,
|
|
pinned_ref = excluded.pinned_ref,
|
|
source_digest = excluded.source_digest,
|
|
source_marketplace_id = excluded.source_marketplace_id,
|
|
trust = excluded.trust,
|
|
capabilities_granted = excluded.capabilities_granted,
|
|
manifest_json = excluded.manifest_json,
|
|
fs_path = excluded.fs_path,
|
|
updated_at = excluded.updated_at
|
|
`).run(
|
|
record.id,
|
|
record.title,
|
|
record.version,
|
|
record.sourceKind,
|
|
record.source,
|
|
record.pinnedRef ?? null,
|
|
record.sourceDigest ?? null,
|
|
record.sourceMarketplaceId ?? null,
|
|
record.trust,
|
|
JSON.stringify(record.capabilitiesGranted ?? []),
|
|
JSON.stringify(record.manifest),
|
|
record.fsPath,
|
|
record.installedAt,
|
|
record.updatedAt,
|
|
);
|
|
}
|
|
|
|
export function deleteInstalledPlugin(db: SqliteDb, id: string): boolean {
|
|
const info = db.prepare(`DELETE FROM installed_plugins WHERE id = ?`).run(id);
|
|
return info.changes > 0;
|
|
}
|