205 lines
6.2 KiB
TypeScript
205 lines
6.2 KiB
TypeScript
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);
|
|
}
|