feat: plugins v1
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_EVENT_DIRECTIONS,
|
||||
PLUGIN_EVENT_SCOPES,
|
||||
type PluginCapabilityId,
|
||||
type TojuPluginManifest
|
||||
} from '../../../../shared-kernel';
|
||||
import type { PluginManifestValidationResult, PluginValidationIssue } from '../models/plugin-runtime.models';
|
||||
|
||||
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/;
|
||||
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
||||
const capabilitySet = new Set<string>(PLUGIN_CAPABILITIES);
|
||||
const eventDirectionSet = new Set<string>(PLUGIN_EVENT_DIRECTIONS);
|
||||
const eventScopeSet = new Set<string>(PLUGIN_EVENT_SCOPES);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, key: string): string | null {
|
||||
const value = record[key];
|
||||
|
||||
return typeof value === 'string' ? value.trim() : null;
|
||||
}
|
||||
|
||||
function pushIssue(
|
||||
issues: PluginValidationIssue[],
|
||||
path: string,
|
||||
message: string,
|
||||
severity: PluginValidationIssue['severity'] = 'error'
|
||||
): void {
|
||||
issues.push({ path, message, severity });
|
||||
}
|
||||
|
||||
function validateStringField(
|
||||
issues: PluginValidationIssue[],
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
options?: { pattern?: RegExp; required?: boolean }
|
||||
): void {
|
||||
const value = readString(record, key);
|
||||
|
||||
if (!value) {
|
||||
if (options?.required !== false) {
|
||||
pushIssue(issues, key, `${key} is required`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.pattern && !options.pattern.test(value)) {
|
||||
pushIssue(issues, key, `${key} has an invalid format`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateStringArray(
|
||||
issues: PluginValidationIssue[],
|
||||
value: unknown,
|
||||
path: string
|
||||
): void {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || !entry.trim())) {
|
||||
pushIssue(issues, path, `${path} must be an array of non-empty strings`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRelationships(issues: PluginValidationIssue[], manifestRecord: Record<string, unknown>): void {
|
||||
const relationships = manifestRecord['relationships'];
|
||||
|
||||
if (relationships === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecord(relationships)) {
|
||||
pushIssue(issues, 'relationships', 'relationships must be an object');
|
||||
return;
|
||||
}
|
||||
|
||||
validateStringArray(issues, relationships['after'], 'relationships.after');
|
||||
validateStringArray(issues, relationships['before'], 'relationships.before');
|
||||
validateStringArray(issues, relationships['conflicts'], 'relationships.conflicts');
|
||||
|
||||
for (const key of ['requires', 'optional'] as const) {
|
||||
const entries = relationships[key];
|
||||
|
||||
if (entries === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(entries)) {
|
||||
pushIssue(issues, `relationships.${key}`, `relationships.${key} must be an array`);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
if (!isRecord(entry) || typeof entry['id'] !== 'string' || !entry['id'].trim()) {
|
||||
pushIssue(issues, `relationships.${key}.${index}`, 'dependency id is required');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateCapabilities(issues: PluginValidationIssue[], manifestRecord: Record<string, unknown>): void {
|
||||
const capabilities = manifestRecord['capabilities'];
|
||||
|
||||
if (capabilities === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(capabilities)) {
|
||||
pushIssue(issues, 'capabilities', 'capabilities must be an array');
|
||||
return;
|
||||
}
|
||||
|
||||
capabilities.forEach((capability, index) => {
|
||||
if (typeof capability !== 'string' || !capabilitySet.has(capability)) {
|
||||
pushIssue(issues, `capabilities.${index}`, `Unknown capability ${String(capability)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateEvents(issues: PluginValidationIssue[], manifestRecord: Record<string, unknown>): void {
|
||||
const events = manifestRecord['events'];
|
||||
|
||||
if (events === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(events)) {
|
||||
pushIssue(issues, 'events', 'events must be an array');
|
||||
return;
|
||||
}
|
||||
|
||||
events.forEach((event, index) => {
|
||||
if (!isRecord(event)) {
|
||||
pushIssue(issues, `events.${index}`, 'event must be an object');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof event['eventName'] !== 'string' || !event['eventName'].trim()) {
|
||||
pushIssue(issues, `events.${index}.eventName`, 'eventName is required');
|
||||
}
|
||||
|
||||
if (typeof event['direction'] !== 'string' || !eventDirectionSet.has(event['direction'])) {
|
||||
pushIssue(issues, `events.${index}.direction`, 'direction is invalid');
|
||||
}
|
||||
|
||||
if (typeof event['scope'] !== 'string' || !eventScopeSet.has(event['scope'])) {
|
||||
pushIssue(issues, `events.${index}.scope`, 'scope is invalid');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function validateTojuPluginManifest(value: unknown): PluginManifestValidationResult {
|
||||
const issues: PluginValidationIssue[] = [];
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return {
|
||||
issues: [{ path: '', message: 'Manifest must be an object', severity: 'error' }],
|
||||
valid: false
|
||||
};
|
||||
}
|
||||
|
||||
validateStringField(issues, value, 'id', { pattern: PLUGIN_ID_PATTERN });
|
||||
validateStringField(issues, value, 'title');
|
||||
validateStringField(issues, value, 'description');
|
||||
validateStringField(issues, value, 'version', { pattern: VERSION_PATTERN });
|
||||
validateStringField(issues, value, 'apiVersion');
|
||||
|
||||
if (value['schemaVersion'] !== 1) {
|
||||
pushIssue(issues, 'schemaVersion', 'schemaVersion must be 1');
|
||||
}
|
||||
|
||||
if (value['kind'] !== 'client' && value['kind'] !== 'library') {
|
||||
pushIssue(issues, 'kind', 'kind must be client or library');
|
||||
}
|
||||
|
||||
if (!isRecord(value['compatibility'])) {
|
||||
pushIssue(issues, 'compatibility', 'compatibility is required');
|
||||
} else {
|
||||
validateStringField(issues, value['compatibility'], 'minimumTojuVersion');
|
||||
}
|
||||
|
||||
if (typeof value['entrypoint'] !== 'string' && value['kind'] === 'client') {
|
||||
pushIssue(issues, 'entrypoint', 'client plugins require an entrypoint');
|
||||
}
|
||||
|
||||
validateCapabilities(issues, value);
|
||||
validateRelationships(issues, value);
|
||||
validateEvents(issues, value);
|
||||
|
||||
return {
|
||||
issues,
|
||||
manifest: issues.some((issue) => issue.severity === 'error') ? undefined : value as unknown as TojuPluginManifest,
|
||||
valid: !issues.some((issue) => issue.severity === 'error')
|
||||
};
|
||||
}
|
||||
|
||||
export function isKnownPluginCapability(value: string): value is PluginCapabilityId {
|
||||
return capabilitySet.has(value);
|
||||
}
|
||||
Reference in New Issue
Block a user