UNPKG

@sprucelabs/spruce-cli

Version:

Command line interface for building Spruce skills.

401 lines (351 loc) • 12 kB
import pathUtil from 'path' import globby from '@sprucelabs/globby' import { Schema, FieldRegistration, fieldRegistrations, normalizeSchemaToIdWithVersion, SchemaError, } from '@sprucelabs/schema' import { personSchema, skillSchema, skillCreatorSchema, locationSchema, organizationSchema, personOrganizationSchema, personLocationSchema, roleSchema, messageSchema, messageSourceSchema, messageTargetSchema, sendMessageSchema, choiceSchema, linkSchema, } from '@sprucelabs/spruce-core-schemas' import { eventResponseUtil } from '@sprucelabs/spruce-event-utils' import { versionUtil } from '@sprucelabs/spruce-skill-utils' import { diskUtil } from '@sprucelabs/spruce-skill-utils' import { CORE_NAMESPACE } from '@sprucelabs/spruce-skill-utils' import { isEqual, uniqBy } from 'lodash' import SpruceError from '../../../errors/SpruceError' import AbstractStore from '../../../stores/AbstractStore' import { InternalUpdateHandler } from '../../../types/cli.types' export const coreSchemas = { personSchema, skillSchema, skillCreatorSchema, locationSchema, organizationSchema, personOrganizationSchema, personLocationSchema, roleSchema, messageSchema, messageSourceSchema, messageTargetSchema, sendMessageSchema, choiceSchema, linkSchema, } interface AddonItem { path: string registration: FieldRegistration isLocal: boolean } export type SchemasByNamespace = Record<string, Schema[]> interface FetchSchemasResults { schemasByNamespace: SchemasByNamespace errors: SpruceError[] } export interface FetchedField { path?: string registration: FieldRegistration isLocal: boolean } interface FetchFieldsResults { errors: SpruceError[] fields: FetchedField[] } const DEFAULT_LOCAL_SCHEMA_DIR = 'src/schemas' export default class SchemaStore extends AbstractStore { public readonly name = 'schema' public async fetchSchemas(options: { localSchemaLookupDir?: string shouldFetchRemoteSchemas?: boolean shouldEnableVersioning?: boolean localNamespace: string shouldFetchCoreSchemas?: boolean moduleToImportFromWhenRemote?: string shouldFetchLocalSchemas?: boolean didUpdateHandler?: InternalUpdateHandler }): Promise<FetchSchemasResults> { const { localSchemaLookupDir: localSchemaDir = DEFAULT_LOCAL_SCHEMA_DIR, shouldFetchLocalSchemas = true, shouldFetchRemoteSchemas = true, shouldEnableVersioning = true, localNamespace, shouldFetchCoreSchemas = true, didUpdateHandler, moduleToImportFromWhenRemote, } = options || {} const results: FetchSchemasResults = { errors: [], schemasByNamespace: {}, } if (shouldFetchCoreSchemas) { results.schemasByNamespace[CORE_NAMESPACE] = Object.values( coreSchemas ).map((schema) => ({ ...schema, namespace: CORE_NAMESPACE, })) } if (shouldFetchLocalSchemas) { const locals = await this.loadLocalSchemas( localSchemaDir, localNamespace, shouldEnableVersioning, didUpdateHandler ) if (moduleToImportFromWhenRemote) { locals.schemas.forEach((local) => { local.moduleToImportFromWhenRemote = moduleToImportFromWhenRemote }) } results.schemasByNamespace[localNamespace] = locals.schemas results.errors.push(...locals.errors) } if (shouldFetchRemoteSchemas) { await this.emitDidFetchSchemasAndMixinResults( localNamespace, results ) } return results } private async emitDidFetchSchemasAndMixinResults( localNamespace: string, results: FetchSchemasResults ) { const schemas: Schema[] = [] for (const namespace in results.schemasByNamespace) { schemas.push(...results.schemasByNamespace[namespace]) } const remoteResults = await this.emitter.emit( 'schema.did-fetch-schemas', { schemas, } ) const { payloads, errors } = eventResponseUtil.getAllResponsePayloadsAndErrors( remoteResults, SpruceError ) if (errors && errors.length > 0) { results.errors.push(...errors) } else { payloads.forEach((payload) => { payload?.schemas?.forEach((schema: Schema) => { this.mixinSchemaOrThrowIfExists( schema, localNamespace, results ) }) }) } } private mixinSchemaOrThrowIfExists( schema: Schema, localNamespace: string, results: FetchSchemasResults ) { const namespace = schema.namespace ?? localNamespace if (!results.schemasByNamespace[namespace]) { results.schemasByNamespace[namespace] = [] } const idWithVersion = normalizeSchemaToIdWithVersion(schema) const match = results.schemasByNamespace[namespace].find((s) => isEqual(normalizeSchemaToIdWithVersion(s), idWithVersion) ) if (!match) { results.schemasByNamespace[namespace].push(schema) } } public async hasLocalSchemas() { const matches = await this.globbyLocalBuilders(DEFAULT_LOCAL_SCHEMA_DIR) return matches.length > 0 } private async loadLocalSchemas( localLookupDir: string, localNamespace: string, shouldEnableVersioning?: boolean, didUpdateHandler?: InternalUpdateHandler ) { const localMatches = await this.globbyLocalBuilders(localLookupDir) const errors: SpruceError[] = [] const schemas: Schema[] = [] didUpdateHandler?.( `Starting import of ${localMatches.length} schema builders...` ) try { const importer = this.Service('import') const imported = await importer.bulkImport(localMatches) for (let c = 0; c < localMatches.length; c++) { try { const local = localMatches[c] let schema = imported[c] let version: undefined | string = this.resolveLocalVersion( shouldEnableVersioning, local, errors ) if (version || shouldEnableVersioning === false) { schema = this.prepareLocalSchema( schema, localNamespace, version, didUpdateHandler ) schemas.push(schema) } } catch (err: any) { errors.push( new SpruceError({ code: 'SCHEMA_FAILED_TO_IMPORT', file: err?.options?.file ?? '**UNKNOWN**', originalError: err?.originalError ?? err, }) ) } } } catch (err: any) { throw new SpruceError({ code: 'SCHEMA_FAILED_TO_IMPORT', file: err?.options?.file ?? '**UNKNOWN**', originalError: err?.originalError ?? err, }) } return { schemas, errors, } } private async globbyLocalBuilders(localLookupDir: string) { return await globby( diskUtil.resolvePath( this.cwd, localLookupDir, '**/*.builder.[t|j]s' ) ) } private resolveLocalVersion( shouldEnableVersioning: boolean | undefined, local: string, errors: SpruceError[] ) { let version: undefined | string try { version = shouldEnableVersioning === false ? undefined : versionUtil.extractVersion(this.cwd, local).constValue } catch (err) { errors.push( new SpruceError({ // @ts-ignore code: 'VERSION_MISSING', friendlyMessage: `It looks like your schema's are not versioned. Make sure schemas are in a directory like src/schemas/${ versionUtil.generateVersion().dirValue }/*.ts`, }) ) } return version } private prepareLocalSchema( schema: Schema, localNamespace: string, version: string | undefined, didUpdateHandler: InternalUpdateHandler | undefined ) { let errors: string[] = [] if (schema.version) { errors.push('version_should_not_be_set') } if (schema.namespace) { errors.push('namespace_should_not_be_set') } schema.namespace = localNamespace if (errors.length > 0) { throw new SchemaError({ code: 'INVALID_SCHEMA', schemaId: schema.id, errors, friendlyMessage: 'You should not set a namespace nor version in your schema builder.', }) } schema.version = version didUpdateHandler?.(`Imported ${schema.id} builder.`) return schema } public async fetchFields(options?: { localAddonsDir?: string }): Promise<FetchFieldsResults> { const { localAddonsDir } = options || {} const coreAddons = fieldRegistrations.map((registration) => { return { registration, isLocal: false, } }) const localErrors: SpruceError[] = [] const localAddons = !localAddonsDir ? [] : await Promise.all( ( await globby([ pathUtil.join(localAddonsDir, '/*Field.addon.[t|j]s'), ]) ).map(async (file: string) => { try { const importService = this.Service('import') const registration = await importService.importDefault<FieldRegistration>( file ) return { path: file, registration, isLocal: true, } } catch (err: any) { localErrors.push( new SpruceError({ code: 'FAILED_TO_IMPORT', file, originalError: err, }) ) return false } }) ) const allFields = uniqBy( [ ...coreAddons, ...(localAddons.filter((addon) => !!addon) as AddonItem[]), ], 'registration.type' ) return { fields: allFields, errors: localErrors, } } }