256 lines
7.8 KiB
TypeScript
256 lines
7.8 KiB
TypeScript
|
|
export interface IManifestDiagnostic {
|
||
|
|
readonly severity: 'error' | 'warn';
|
||
|
|
readonly code: string;
|
||
|
|
readonly message: string;
|
||
|
|
readonly field?: string;
|
||
|
|
readonly componentIndex?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IManifestValidationResult {
|
||
|
|
readonly valid: boolean;
|
||
|
|
readonly errors: string[];
|
||
|
|
readonly diagnostics: ReadonlyArray<IManifestDiagnostic>;
|
||
|
|
}
|
||
|
|
|
||
|
|
type JsonRecord = Record<string, unknown>;
|
||
|
|
|
||
|
|
const SUPPORTED_FS_POLICIES = new Set([
|
||
|
|
'none',
|
||
|
|
'matrix-only',
|
||
|
|
'project-readonly',
|
||
|
|
'project-readwrite',
|
||
|
|
'global-readonly',
|
||
|
|
]);
|
||
|
|
const SUPPORTED_HOST_API_POLICIES = new Set([
|
||
|
|
'none',
|
||
|
|
'same-origin',
|
||
|
|
]);
|
||
|
|
|
||
|
|
function asRecord(value: unknown): JsonRecord | null {
|
||
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return value as JsonRecord;
|
||
|
|
}
|
||
|
|
|
||
|
|
function optionalTrimmedString(value: unknown): string | undefined {
|
||
|
|
if (typeof value !== 'string') {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
const trimmed = value.trim();
|
||
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function validateInstallHook(
|
||
|
|
diagnostics: IManifestDiagnostic[],
|
||
|
|
install: JsonRecord,
|
||
|
|
phase: 'validate' | 'migrate' | 'seed' | 'verify',
|
||
|
|
): void {
|
||
|
|
const hook = asRecord(install[phase]);
|
||
|
|
if (!hook) {
|
||
|
|
if (install[phase] !== undefined) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_INSTALL_HOOK_INVALID',
|
||
|
|
field: `install.${phase}`,
|
||
|
|
message: `install.${phase} must be an object when provided`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!optionalTrimmedString(hook.script)) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_INSTALL_HOOK_SCRIPT_INVALID',
|
||
|
|
field: `install.${phase}.script`,
|
||
|
|
message: `install.${phase}.script must be a non-empty string`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function pushError(
|
||
|
|
diagnostics: IManifestDiagnostic[],
|
||
|
|
entry: Omit<IManifestDiagnostic, 'severity'>
|
||
|
|
): void {
|
||
|
|
diagnostics.push({ ...entry, severity: 'error' });
|
||
|
|
}
|
||
|
|
|
||
|
|
export function validateManifestForMxCli(manifest: unknown): IManifestValidationResult {
|
||
|
|
const diagnostics: IManifestDiagnostic[] = [];
|
||
|
|
const manifestRecord = asRecord(manifest);
|
||
|
|
if (!manifestRecord) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_MANIFEST_NOT_OBJECT',
|
||
|
|
message: 'Manifest must be a JSON object',
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
valid: false,
|
||
|
|
diagnostics,
|
||
|
|
errors: diagnostics.map((entry) => entry.message),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!optionalTrimmedString(manifestRecord.name)) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_NAME_INVALID',
|
||
|
|
field: 'name',
|
||
|
|
message: 'name must be a non-empty string',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!optionalTrimmedString(manifestRecord.version)) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_VERSION_INVALID',
|
||
|
|
field: 'version',
|
||
|
|
message: 'version must be a non-empty string',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const runtime = asRecord(manifestRecord.runtime);
|
||
|
|
if (!runtime || !optionalTrimmedString(runtime.language) || !optionalTrimmedString(runtime.entry)) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_RUNTIME_INVALID',
|
||
|
|
field: 'runtime',
|
||
|
|
message: 'runtime.language and runtime.entry are required',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const permissions = asRecord(manifestRecord.permissions);
|
||
|
|
const fsPolicy = permissions ? optionalTrimmedString(permissions.fsPolicy) : undefined;
|
||
|
|
if (!fsPolicy || !SUPPORTED_FS_POLICIES.has(fsPolicy)) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_PERMISSIONS_FSPOLICY_INVALID',
|
||
|
|
field: 'permissions.fsPolicy',
|
||
|
|
message: 'permissions.fsPolicy must be one of none|matrix-only|project-readonly|project-readwrite|global-readonly',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const hostApi = permissions ? optionalTrimmedString(permissions.hostApi) : undefined;
|
||
|
|
if (permissions?.hostApi !== undefined && (!hostApi || !SUPPORTED_HOST_API_POLICIES.has(hostApi))) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_PERMISSIONS_HOSTAPI_INVALID',
|
||
|
|
field: 'permissions.hostApi',
|
||
|
|
message: 'permissions.hostApi must be one of none|same-origin',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
for (const field of ['network', 'subprocess', 'env']) {
|
||
|
|
if (permissions && permissions[field] !== undefined && typeof permissions[field] !== 'boolean') {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_PERMISSIONS_BOOLEAN_INVALID',
|
||
|
|
field: `permissions.${field}`,
|
||
|
|
message: `permissions.${field} must be boolean when provided`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const install = asRecord(manifestRecord.install);
|
||
|
|
if (manifestRecord.install !== undefined && !install) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_INSTALL_INVALID',
|
||
|
|
field: 'install',
|
||
|
|
message: 'install must be an object when provided',
|
||
|
|
});
|
||
|
|
} else if (install) {
|
||
|
|
validateInstallHook(diagnostics, install, 'validate');
|
||
|
|
validateInstallHook(diagnostics, install, 'migrate');
|
||
|
|
validateInstallHook(diagnostics, install, 'seed');
|
||
|
|
validateInstallHook(diagnostics, install, 'verify');
|
||
|
|
}
|
||
|
|
|
||
|
|
const componentsValue = manifestRecord.components;
|
||
|
|
if (!Array.isArray(componentsValue)) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_COMPONENTS_NOT_ARRAY',
|
||
|
|
field: 'components',
|
||
|
|
message: 'components must be an array',
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
componentsValue.forEach((entry, componentIndex) => {
|
||
|
|
const component = asRecord(entry);
|
||
|
|
if (!component) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_COMPONENT_NOT_OBJECT',
|
||
|
|
field: 'components',
|
||
|
|
componentIndex,
|
||
|
|
message: `component[${componentIndex}] must be an object`,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const type = optionalTrimmedString(component.type);
|
||
|
|
if (!type) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_COMPONENT_TYPE_INVALID',
|
||
|
|
field: 'type',
|
||
|
|
componentIndex,
|
||
|
|
message: `component[${componentIndex}].type must be a non-empty string`,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const exportName = optionalTrimmedString(component.export);
|
||
|
|
if (component.export !== undefined && !exportName) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_COMPONENT_EXPORT_INVALID',
|
||
|
|
field: 'export',
|
||
|
|
componentIndex,
|
||
|
|
message: `component[${componentIndex}].export must be a non-empty string when provided`,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const mount = optionalTrimmedString(component.mount);
|
||
|
|
if (component.mount !== undefined && !mount) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_COMPONENT_MOUNT_INVALID',
|
||
|
|
field: 'mount',
|
||
|
|
componentIndex,
|
||
|
|
message: `component[${componentIndex}].mount must be a non-empty string when provided`,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (component.autoStart !== undefined && typeof component.autoStart !== 'boolean') {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_COMPONENT_AUTOSTART_INVALID',
|
||
|
|
field: 'autoStart',
|
||
|
|
componentIndex,
|
||
|
|
message: `component[${componentIndex}].autoStart must be boolean when provided`,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (component.props !== undefined && !asRecord(component.props)) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_COMPONENT_PROPS_INVALID',
|
||
|
|
field: 'props',
|
||
|
|
componentIndex,
|
||
|
|
message: `component[${componentIndex}].props must be an object when provided`,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const componentFsPolicy = optionalTrimmedString(component.fsPolicy);
|
||
|
|
if (
|
||
|
|
component.fsPolicy !== undefined
|
||
|
|
&& (!componentFsPolicy || !SUPPORTED_FS_POLICIES.has(componentFsPolicy))
|
||
|
|
) {
|
||
|
|
pushError(diagnostics, {
|
||
|
|
code: 'MXPKG_COMPONENT_FSPOLICY_INVALID',
|
||
|
|
field: 'fsPolicy',
|
||
|
|
componentIndex,
|
||
|
|
message: `component[${componentIndex}].fsPolicy must be one of matrix-only|project-readonly|project-readwrite|global-readonly|none`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const errors = diagnostics
|
||
|
|
.filter((entry) => entry.severity === 'error')
|
||
|
|
.map((entry) => entry.message);
|
||
|
|
|
||
|
|
return {
|
||
|
|
valid: errors.length === 0,
|
||
|
|
diagnostics,
|
||
|
|
errors,
|
||
|
|
};
|
||
|
|
}
|