126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
import { readFileSync } from 'fs';
|
|
import { resolve } from 'path';
|
|
|
|
export interface SkillRegistryEntry {
|
|
id: string;
|
|
name: string;
|
|
version: string;
|
|
source: string;
|
|
summary: string;
|
|
publisher?: string;
|
|
homepage?: string;
|
|
sha256?: string;
|
|
}
|
|
|
|
export interface SkillRegistryCatalog {
|
|
skills: SkillRegistryEntry[];
|
|
}
|
|
|
|
export type SkillRegistrySource =
|
|
| { type: 'file'; path: string }
|
|
| { type: 'url'; url: string };
|
|
|
|
export interface SkillRegistryLoadOptions {
|
|
timeoutMs?: number;
|
|
fetchImpl?: typeof fetch;
|
|
}
|
|
|
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
const SKILL_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
|
|
|
function describePath(ctx: string, key: string): string {
|
|
return `${ctx}.${key}`;
|
|
}
|
|
|
|
function readRequiredString(value: unknown, ctx: string, key: string): string {
|
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
throw new Error(`Invalid registry entry: expected non-empty string at ${describePath(ctx, key)}`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function readOptionalString(value: unknown, ctx: string, key: string): string | undefined {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
throw new Error(`Invalid registry entry: expected non-empty string at ${describePath(ctx, key)}`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function parseSkillEntry(raw: unknown, index: number): SkillRegistryEntry {
|
|
const ctx = `skills[${index}]`;
|
|
if (!raw || typeof raw !== 'object') {
|
|
throw new Error(`Invalid registry entry: expected object at ${ctx}`);
|
|
}
|
|
|
|
const obj = raw as Record<string, unknown>;
|
|
const id = readRequiredString(obj.id, ctx, 'id');
|
|
if (!SKILL_ID_RE.test(id)) {
|
|
throw new Error(`Invalid registry entry: ${ctx}.id must match ${SKILL_ID_RE.source}`);
|
|
}
|
|
|
|
const source = readRequiredString(obj.source, ctx, 'source');
|
|
if (!source.includes('/') && !source.includes(':') && !source.startsWith('.')) {
|
|
throw new Error(`Invalid registry entry: ${ctx}.source must be a path or URL`);
|
|
}
|
|
|
|
return {
|
|
id,
|
|
name: readRequiredString(obj.name, ctx, 'name'),
|
|
version: readRequiredString(obj.version, ctx, 'version'),
|
|
source,
|
|
summary: readRequiredString(obj.summary, ctx, 'summary'),
|
|
publisher: readOptionalString(obj.publisher, ctx, 'publisher'),
|
|
homepage: readOptionalString(obj.homepage, ctx, 'homepage'),
|
|
sha256: readOptionalString(obj.sha256, ctx, 'sha256'),
|
|
};
|
|
}
|
|
|
|
export function parseSkillRegistryCatalog(raw: unknown): SkillRegistryCatalog {
|
|
if (!raw || typeof raw !== 'object') {
|
|
throw new Error('Invalid registry catalog: expected top-level object');
|
|
}
|
|
|
|
const obj = raw as Record<string, unknown>;
|
|
if (!Array.isArray(obj.skills)) {
|
|
throw new Error('Invalid registry catalog: expected skills array');
|
|
}
|
|
|
|
return {
|
|
skills: obj.skills.map((entry, index) => parseSkillEntry(entry, index)),
|
|
};
|
|
}
|
|
|
|
async function fetchRegistryJson(url: string, opts: SkillRegistryLoadOptions): Promise<unknown> {
|
|
if (!url.startsWith('https://')) {
|
|
throw new Error(`Registry URL must use https:// (${url})`);
|
|
}
|
|
|
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
const signal = AbortSignal.timeout(timeoutMs);
|
|
const response = await fetchImpl(url, { signal });
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Registry fetch failed: HTTP ${response.status}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function loadSkillRegistryCatalog(
|
|
source: SkillRegistrySource,
|
|
opts: SkillRegistryLoadOptions = {},
|
|
): Promise<SkillRegistryCatalog> {
|
|
if (source.type === 'file') {
|
|
const absPath = resolve(source.path);
|
|
const raw = readFileSync(absPath, 'utf8');
|
|
return parseSkillRegistryCatalog(JSON.parse(raw) as unknown);
|
|
}
|
|
|
|
const json = await fetchRegistryJson(source.url, opts);
|
|
return parseSkillRegistryCatalog(json);
|
|
}
|