hivecast-sdk/packages/cli/src/utils/manifestValidator.ts

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,
};
}