@sprucelabs/spruce-cli
Version: 
Command line interface for building Spruce skills.
361 lines (307 loc) • 12.8 kB
text/typescript
import pathUtil from 'path'
import { SchemaTemplateItem, FieldTemplateItem } from '@sprucelabs/schema'
import { CORE_NAMESPACE, diskUtil } from '@sprucelabs/spruce-skill-utils'
import { ValueTypes } from '@sprucelabs/spruce-templates'
import uniq from 'lodash/uniq'
import { SpruceSchemas } from '#spruce/schemas/schemas.types'
import syncSchemasActionSchema from '#spruce/schemas/spruceCli/v2020_07_22/syncSchemasOptions.schema'
import SpruceError from '../../../errors/SpruceError'
import SchemaTemplateItemBuilder from '../../../templateItemBuilders/SchemaTemplateItemBuilder'
import { GeneratedFile } from '../../../types/cli.types'
import actionUtil from '../../../utilities/action.utility'
import AbstractAction from '../../AbstractAction'
import { FeatureActionResponse } from '../../features.types'
import schemaDiskUtil from '../utilities/schemaDisk.utility'
import ValueTypeBuilder from '../ValueTypeBuilder'
type OptionsSchema =
    SpruceSchemas.SpruceCli.v2020_07_22.SyncSchemasOptionsSchema
type Options = SpruceSchemas.SpruceCli.v2020_07_22.SyncSchemasOptions
export default class SyncAction extends AbstractAction<OptionsSchema> {
    public optionsSchema = syncSchemasActionSchema
    public commandAliases = ['sync.schemas']
    public invocationMessage = 'Building schemas and generating types... 📃'
    private readonly schemaWriter = this.Writer('schema')
    private readonly schemaStore = this.Store('schema')
    public async execute(options: Options): Promise<FeatureActionResponse> {
        const normalizedOptions = this.validateAndNormalizeOptions(options)
        const isInCoreSchemasModule =
            this.Service('pkg').get('name') ===
            '@sprucelabs/spruce-core-schemas'
        let {
            schemaTypesDestinationDirOrFile,
            fieldTypesDestinationDir,
            schemaLookupDir,
            addonsLookupDir,
            shouldEnableVersioning,
            globalSchemaNamespace,
            shouldFetchRemoteSchemas,
            shouldGenerateCoreSchemaTypes = isInCoreSchemasModule,
            shouldFetchLocalSchemas,
            generateFieldTypes,
            generateStandaloneTypesFile,
            deleteDestinationDirIfNoSchemas,
            shouldFetchCoreSchemas,
            registerBuiltSchemas,
            syncingMessage,
            deleteOrphanedSchemas,
            moduleToImportFromWhenRemote,
            shouldInstallMissingDependencies,
        } = normalizedOptions
        this.ui.startLoading('Loading details about your skill... 🧐')
        let localNamespace =
            await this.Store('skill').loadCurrentSkillsNamespace()
        let shouldImportCoreSchemas = true
        if (shouldGenerateCoreSchemaTypes) {
            shouldFetchRemoteSchemas = false
            shouldFetchLocalSchemas = true
            shouldFetchCoreSchemas = false
            registerBuiltSchemas = true
            generateStandaloneTypesFile = true
            shouldImportCoreSchemas = false
            localNamespace = CORE_NAMESPACE
        }
        let coreSyncResults: FeatureActionResponse | undefined
        const {
            resolvedFieldTypesDestination,
            resolvedSchemaTypesDestinationDirOrFile,
            resolvedSchemaTypesDestination,
        } = schemaDiskUtil.resolveTypeFilePaths({
            cwd: this.cwd,
            generateStandaloneTypesFile,
            schemaTypesDestinationDirOrFile,
            fieldTypesDestinationDir,
        })
        this.ui.startLoading('Generating field types...')
        const { fieldTemplateItems, fieldErrors, generateFieldFiles } =
            await this.generateFieldTemplateItems({
                addonsLookupDir,
                shouldGenerateFieldTypes: generateFieldTypes,
                resolvedFieldTypesDestination,
            })
        this.ui.startLoading(syncingMessage)
        const schemaErrors: SpruceError[] = []
        let schemaTemplateItems: SchemaTemplateItem[] | undefined
        let typeResults: GeneratedFile[] = []
        try {
            const templateResults = await this.generateSchemaTemplateItems({
                schemaLookupDir,
                shouldFetchLocalSchemas,
                moduleToImportFromWhenRemote,
                resolvedSchemaTypesDestinationDirOrFile,
                shouldEnableVersioning,
                shouldFetchRemoteSchemas,
                shouldFetchCoreSchemas,
                localNamespace,
            })
            schemaErrors.push(...templateResults.schemaErrors)
            schemaTemplateItems = templateResults.schemaTemplateItems
        } catch (err: any) {
            schemaErrors.push(err)
        }
        if (schemaErrors.length === 0 && schemaTemplateItems) {
            if (
                deleteDestinationDirIfNoSchemas &&
                schemaTemplateItems.length === 0
            ) {
                diskUtil.deleteDir(resolvedSchemaTypesDestinationDirOrFile)
                return {}
            }
            if (deleteOrphanedSchemas) {
                this.ui.startLoading('Identifying orphaned schemas...')
                await schemaDiskUtil.deleteOrphanedSchemas(
                    resolvedSchemaTypesDestinationDirOrFile,
                    schemaTemplateItems
                )
            }
            await this.optionallyInstallRemoteModules(
                schemaTemplateItems,
                shouldInstallMissingDependencies
            )
            let valueTypes: ValueTypes | undefined
            try {
                valueTypes = await this.generateValueTypes({
                    resolvedDestination: resolvedFieldTypesDestination,
                    fieldTemplateItems,
                    schemaTemplateItems,
                    globalSchemaNamespace: globalSchemaNamespace ?? undefined,
                })
            } catch (err: any) {
                schemaErrors.push(err)
            }
            if (valueTypes) {
                try {
                    this.ui.startLoading('Determining what changed... ⚡️')
                    typeResults = await this.schemaWriter.writeSchemasAndTypes(
                        resolvedSchemaTypesDestination,
                        {
                            registerBuiltSchemas,
                            fieldTemplateItems,
                            schemaTemplateItems,
                            shouldImportCoreSchemas,
                            valueTypes,
                            globalSchemaNamespace:
                                globalSchemaNamespace ?? undefined,
                            typesTemplate: generateStandaloneTypesFile
                                ? 'schema/core.schemas.types.ts.hbs'
                                : undefined,
                        }
                    )
                } catch (err: any) {
                    schemaErrors.push(err)
                }
            }
        }
        const p = resolvedSchemaTypesDestination
        diskUtil.deleteEmptyDirs(diskUtil.isDir(p) ? p : pathUtil.dirname(p))
        this.ui.stopLoading()
        const errors = [...schemaErrors, ...fieldErrors]
        return actionUtil.mergeActionResults(coreSyncResults || {}, {
            files: [...typeResults, ...generateFieldFiles],
            errors: errors.length > 0 ? errors : undefined,
            meta: {
                schemaTemplateItems,
                fieldTemplateItems,
            },
        })
    }
    private async optionallyInstallRemoteModules(
        schemaTemplateItems: SchemaTemplateItem[],
        forceInstall?: boolean
    ) {
        const modules = uniq(
            schemaTemplateItems
                .map((item) => item.importFrom)
                .filter((i) => !!i)
        ) as string[]
        const notInstalled: string[] = []
        const pkg = this.Service('pkg')
        for (const m of modules) {
            if (!pkg.isInstalled(m) && notInstalled.indexOf(m) === -1) {
                notInstalled.push(m)
            }
        }
        if (notInstalled.length > 0) {
            if (!forceInstall) {
                this.ui.stopLoading()
                this.ui.renderSection({
                    headline: `Missing ${notInstalled.length} module${
                        notInstalled.length === 1 ? '' : 's'
                    }`,
                    lines: [
                        `Looks like I need to install the following modules to continue to sync schemas:`,
                        '',
                        ...notInstalled,
                    ],
                })
                const confirm = await this.ui.confirm('Should we do that now?')
                if (!confirm) {
                    throw new SpruceError({
                        code: 'ACTION_CANCELLED',
                        friendlyMessage: `I can't sync schemas because of the missing modules.`,
                    })
                }
            }
            this.ui.startLoading(
                `Installing ${notInstalled.length} missing module${
                    notInstalled.length === 1 ? '' : 's...'
                }`
            )
            const pkg = this.Service('pkg')
            await pkg.install(notInstalled)
            this.ui.stopLoading()
        }
    }
    private async generateSchemaTemplateItems(options: {
        schemaLookupDir: string | undefined
        localNamespace: string
        resolvedSchemaTypesDestinationDirOrFile: string
        shouldFetchLocalSchemas: boolean
        moduleToImportFromWhenRemote?: string
        shouldEnableVersioning: boolean
        shouldFetchRemoteSchemas: boolean
        shouldFetchCoreSchemas: boolean
    }) {
        const {
            schemaLookupDir,
            resolvedSchemaTypesDestinationDirOrFile,
            shouldEnableVersioning,
            shouldFetchRemoteSchemas,
            shouldFetchCoreSchemas,
            shouldFetchLocalSchemas,
            localNamespace,
            moduleToImportFromWhenRemote,
        } = options
        this.ui.startLoading('Loading builders...')
        const { schemasByNamespace, errors: schemaErrors } =
            await this.schemaStore.fetchSchemas({
                localSchemaLookupDir: schemaLookupDir,
                shouldFetchLocalSchemas,
                shouldFetchRemoteSchemas,
                shouldEnableVersioning,
                moduleToImportFromWhenRemote,
                localNamespace,
                shouldFetchCoreSchemas,
                didUpdateHandler: (message) => {
                    this.ui.startLoading(message)
                },
            })
        const hashSpruceDestination =
            resolvedSchemaTypesDestinationDirOrFile.replace(
                diskUtil.resolveHashSprucePath(this.cwd),
                '#spruce'
            )
        let total = 0
        let totalNamespaces = 0
        for (const namespace of Object.keys(schemasByNamespace)) {
            totalNamespaces++
            total += schemasByNamespace[namespace].length
        }
        this.ui.startLoading(
            `Building ${total} schemas from ${totalNamespaces} namespaces.`
        )
        const schemaTemplateItemBuilder = new SchemaTemplateItemBuilder(
            localNamespace
        )
        const schemaTemplateItems: SchemaTemplateItem[] =
            schemaTemplateItemBuilder.buildTemplateItems(
                schemasByNamespace,
                hashSpruceDestination
            )
        return { schemaTemplateItems, schemaErrors }
    }
    private async generateFieldTemplateItems(options: {
        addonsLookupDir: string
        shouldGenerateFieldTypes: boolean
        resolvedFieldTypesDestination: string
    }) {
        const {
            addonsLookupDir,
            shouldGenerateFieldTypes: generateFieldTypes,
            resolvedFieldTypesDestination,
        } = options
        const action = this.Action('schema', 'syncFields')
        const results = await action.execute({
            fieldTypesDestinationDir: resolvedFieldTypesDestination,
            addonsLookupDir,
            generateFieldTypes,
        })
        return {
            generateFieldFiles: results.files ?? [],
            fieldTemplateItems: results.meta?.fieldTemplateItems ?? [],
            fieldErrors: results.errors ?? [],
        }
    }
    private async generateValueTypes(options: {
        resolvedDestination: string
        fieldTemplateItems: FieldTemplateItem[]
        schemaTemplateItems: SchemaTemplateItem[]
        globalSchemaNamespace?: string
    }) {
        this.ui.startLoading('Generating value types...')
        const builder = new ValueTypeBuilder(
            this.schemaWriter,
            this.Service('import')
        )
        return builder.generateValueTypes(options)
    }
}