UNPKG

@sprucelabs/spruce-cli

Version:

Command line interface for building Spruce skills.

542 lines (446 loc) • 17.3 kB
import { validateSchemaValues } from '@sprucelabs/schema' import { HASH_SPRUCE_DIR, diskUtil } from '@sprucelabs/spruce-skill-utils' import { uniq } from 'lodash' import merge from 'lodash/merge' import SpruceError from '../errors/SpruceError' import ServiceFactory, { Service, ServiceProvider, ServiceMap, } from '../services/ServiceFactory' import { InternalUpdateHandler, NpmPackage } from '../types/cli.types' import AbstractFeature, { FeatureDependency } from './AbstractFeature' import { InstallFeatureOptions, FeatureInstallResponse, FeatureCode, InstallFeature, FeatureMap, } from './features.types' export class FeatureInstallerImpl implements ServiceProvider, FeatureInstaller { public cwd: string private featureMap: Partial<FeatureMap> = {} private serviceFactory: ServiceFactory private featuresMarkedAsSkippedThisRun: FeatureCode[] = [] public static startInFlightIntertainmentHandler?: ( didUpdateHandler: (handler: (message: string) => void) => void ) => void public static stopInFlightIntertainmentHandler?: () => void private packagesToInstall: string[] = [] private devPackagesToInstall: string[] = [] private featuresToMarkAsInstalled: string[] = [] private afterPackageInstalls: InstallFeature[] = [] private pendingFeatureInstalls: Record<string, boolean> = {} public constructor(cwd: string, serviceFactory: ServiceFactory) { this.cwd = cwd this.serviceFactory = serviceFactory } public isInSpruceModule(): boolean { return diskUtil.doesDirExist( diskUtil.resolvePath(this.cwd, HASH_SPRUCE_DIR) ) } public async isInstalled(code: FeatureCode): Promise<boolean> { const feature = this.getFeature(code) if (feature.isInstalled) { return feature.isInstalled() } return this.Service('settings').isMarkedAsInstalled(code) } public markAsSkippedThisRun(code: FeatureCode) { if (!this.isMarkedAsSkipped(code)) { this.featuresMarkedAsSkippedThisRun.push(code) } } public markAsPermanentlySkipped(code: FeatureCode) { if (!this.isMarkedAsSkipped(code)) { this.Service('settings').markAsPermanentlySkipped(code) } } public isMarkedAsSkipped(code: FeatureCode) { return ( this.featuresMarkedAsSkippedThisRun.indexOf(code) > -1 || this.Service('settings').isMarkedAsPermanentlySkipped(code) ) } public mapFeature<C extends FeatureCode>(code: C, feature: FeatureMap[C]) { this.featureMap[code] = feature } public getFeature<C extends FeatureCode>(code: C): FeatureMap[C] { const feature = this.featureMap[code] if (!feature) { throw new SpruceError({ code: 'INVALID_FEATURE_CODE', featureCode: code, }) } return feature as FeatureMap[C] } public async areInstalled(codes: FeatureCode[]) { const results = await Promise.all( codes.map((f) => { return this.isInstalled(f) }) ) for (const result of results) { if (!result) { return false } } return true } public getFeatureDependencies<C extends FeatureCode>( featureCode: C // trackedFeatures: FeatureDependency[] = [] ): FeatureDependency[] { let deps = this.getFeatureDependenciesIncludingSelf( { code: featureCode, isRequired: true }, [] // trackedFeatures ).filter((f) => f.code !== featureCode) deps = this.sortFeatures(deps) return deps } private getFeatureDependenciesIncludingSelf( featureDependency: FeatureDependency, trackedFeatures: FeatureDependency[] = [] ): FeatureDependency[] { const features: FeatureDependency[] = [] if (!this.isDependencyInTracker(trackedFeatures, featureDependency)) { features.push(featureDependency) trackedFeatures.push(featureDependency) } const feature = this.getFeature(featureDependency.code) const dependencies = feature.dependencies for (const dependency of dependencies) { if (!this.isDependencyInTracker(trackedFeatures, dependency)) { features.push(dependency) trackedFeatures.push(dependency) } } for (const dependency of dependencies) { let dependencyDependencies = this.getFeatureDependenciesIncludingSelf( dependency, trackedFeatures ) if (!dependency.isRequired) { dependencyDependencies = dependencyDependencies.map((f) => ({ ...f, isRequired: false, })) } features.push(...dependencyDependencies) } return features } private isDependencyInTracker( trackedFeatures: FeatureDependency[], dependency: FeatureDependency ) { return !!trackedFeatures.find((f) => f.code === dependency.code) } public async install( options: InstallFeatureOptions ): Promise<FeatureInstallResponse> { let { features, installFeatureDependencies = true, didUpdateHandler, } = options const shouldAllowEntertainment = !!features.find( (f) => f.code === 'skill' ) if ( FeatureInstallerImpl.startInFlightIntertainmentHandler && shouldAllowEntertainment ) { FeatureInstallerImpl.startInFlightIntertainmentHandler( (handler: InternalUpdateHandler) => { didUpdateHandler = handler } ) } this.pendingFeatureInstalls = {} let results: FeatureInstallResponse = {} let dependenciesToInstall: FeatureDependency[] = [] for (const f of features) { const code = f.code didUpdateHandler?.(`Checking if ${code} is installed...`) const isInstalled = await this.isInstalled(code) if (!isInstalled && installFeatureDependencies) { didUpdateHandler?.(`It is not, checking dependencies...`) dependenciesToInstall = dependenciesToInstall.concat( this.getFeatureDependenciesIncludingSelf({ code, isRequired: true, }) ) } else if (!isInstalled) { dependenciesToInstall.push({ code, isRequired: true }) } } dependenciesToInstall = uniq(dependenciesToInstall) dependenciesToInstall = this.sortFeatures(dependenciesToInstall) for (const { code, isRequired } of dependenciesToInstall) { const isInstalled = await this.isInstalledOrPendingInstall(code) if (!isInstalled && isRequired) { const installOptions = //@ts-ignore options.features.find((f) => f.code === code)?.options const installFeature = { code, options: installOptions, } as InstallFeature didUpdateHandler?.( `Installing the ${installFeature.code} feature....` ) const installResults = await this.installFeature( installFeature, didUpdateHandler ) results = merge(results, installResults) } } const pendingResults = await this.installAllPending(didUpdateHandler) results = merge(results, pendingResults) if ( FeatureInstallerImpl.stopInFlightIntertainmentHandler && shouldAllowEntertainment ) { FeatureInstallerImpl.stopInFlightIntertainmentHandler() } return results } private isInstalledOrPendingInstall(code: string) { return ( this.pendingFeatureInstalls[code] || this.isInstalled(code as any) ) } private async installFeature( installFeature: InstallFeature, didUpdateHandler?: InternalUpdateHandler ): Promise<FeatureInstallResponse> { this.pendingFeatureInstalls[installFeature.code] = true const feature = this.getFeature(installFeature.code) as AbstractFeature if (feature.optionsSchema) { validateSchemaValues( feature.optionsSchema, //@ts-ignore installFeature.options ?? {} ) } didUpdateHandler?.(`Running before package install hook...`) const beforeInstallResults = await feature.beforePackageInstall( //@ts-ignore installFeature.options ) if (beforeInstallResults.cwd) { this.cwd = beforeInstallResults.cwd } didUpdateHandler?.(`Installing package dependencies...`) const packagesInstalled = await this.queueInstallPackageDependenciesWithoutEntertainment( feature, didUpdateHandler ) didUpdateHandler?.(`Running after package install hook...`) this.afterPackageInstalls.push(installFeature) if (!feature.isInstalled) { this.featuresToMarkAsInstalled.push(feature.code) } const files = [...(beforeInstallResults.files ?? [])] return { files: files ?? undefined, packagesInstalled, } } public async installPackageDependencies( feature: AbstractFeature, didUpdateHandler?: InternalUpdateHandler ) { return this.installPackageDependenciesForFeatures( [feature], didUpdateHandler ) } public async installPackageDependenciesForFeatures( features: AbstractFeature[], didUpdateHandler?: InternalUpdateHandler ) { if (FeatureInstallerImpl.startInFlightIntertainmentHandler) { FeatureInstallerImpl.startInFlightIntertainmentHandler( (handler: InternalUpdateHandler) => { didUpdateHandler = handler } ) } for (const feature of features) { await this.queueInstallPackageDependenciesWithoutEntertainment( feature, didUpdateHandler ) } await this.installAllPending(didUpdateHandler) if (FeatureInstallerImpl.stopInFlightIntertainmentHandler) { FeatureInstallerImpl.stopInFlightIntertainmentHandler() } } private async installAllPending( didUpdateHandler?: InternalUpdateHandler ): Promise<FeatureInstallResponse> { const pkgService = this.Service('pkg') if (this.packagesToInstall.length > 0) { didUpdateHandler?.( `Installing ${this.packagesToInstall.length} node module${ this.packagesToInstall.length === 1 ? '' : 's' }. Please be patient.` ) await pkgService.install(this.packagesToInstall, {}) } if (this.devPackagesToInstall.length > 0) { didUpdateHandler?.( `Now installing ${this.devPackagesToInstall.length} DEV node module${ this.devPackagesToInstall.length === 1 ? '' : 's' }. Please be patient.` ) await pkgService.install(this.devPackagesToInstall, { isDev: true, }) } this.packagesToInstall = [] this.devPackagesToInstall = [] const settings = this.Service('settings') for (const code of this.featuresToMarkAsInstalled) { settings.markAsInstalled(code) } this.featuresToMarkAsInstalled = [] let results: FeatureInstallResponse = {} for (const installFeature of this.afterPackageInstalls) { const feature = this.getFeature(installFeature.code) const afterInstallResults = await feature.afterPackageInstall( //@ts-ignore installFeature.options ) results = merge(results, afterInstallResults) } return results } private async queueInstallPackageDependenciesWithoutEntertainment( feature: AbstractFeature, didUpdateHandler?: InternalUpdateHandler ) { const packagesInstalled: NpmPackage[] = [] const pkgService = this.Service('pkg') feature.packageDependencies?.forEach((pkg) => { const packageName = pkgService.buildPackageName(pkg) packagesInstalled.push(pkg) didUpdateHandler?.(`Checking node dependency: ${pkg.name}`) if ( pkg.isDev && this.devPackagesToInstall.indexOf(packageName) === -1 ) { this.devPackagesToInstall.push(packageName) } else if ( !pkg.isDev && this.packagesToInstall.indexOf(packageName) === -1 ) { this.packagesToInstall.push(packageName) } }) if (this.packagesToInstall.length > 0) { didUpdateHandler?.( `Queueing install of ${this.packagesToInstall.length} node dependenc${ this.packagesToInstall.length === 1 ? 'y.' : 'ies for ' + this.getFeatureNameAndDesc(feature) + '...' }.` ) } if (this.devPackagesToInstall.length > 0) { didUpdateHandler?.( `Queueing install of ${ this.devPackagesToInstall.length } DEV node dependenc${ this.devPackagesToInstall.length === 1 ? 'y.' : 'ies for ' + this.getFeatureNameAndDesc(feature) + '. 🤘' }.` ) } return packagesInstalled } private getFeatureNameAndDesc(feature: AbstractFeature) { return `${feature.nameReadable}${ feature.description ? ' (' + feature.description + ')' : '' }` } private sortFeatures( featureDependencies: FeatureDependency[] ): FeatureDependency[] { return [...featureDependencies].sort((a, b) => { const aFeature = this.getFeature(a.code) const bFeature = this.getFeature(b.code) const aDependsOnB = aFeature.dependencies.find( (d) => d.code === b.code ) const bDependsOnA = bFeature.dependencies.find( (d) => d.code === a.code ) if ( aDependsOnB || aFeature.installOrderWeight < bFeature.installOrderWeight ) { return 1 } else if ( bDependsOnA || aFeature.installOrderWeight > bFeature.installOrderWeight ) { return -1 } return 0 }) } public Service<S extends Service>(type: S, cwd?: string): ServiceMap[S] { return this.serviceFactory.Service(cwd ?? this.cwd, type) } public getOptionsForFeature(code: FeatureCode) { return this.getFeature(code).optionsSchema } public getAllCodes(): FeatureCode[] { const codes = Object.keys(this.featureMap) as FeatureCode[] return this.sortFeatures( codes.map((code) => ({ code, isRequired: true })) ).map((dep) => dep.code) } public async getInstalledFeatures() { const installed = await Promise.all( this.getAllCodes().map(async (code) => { const isInstalled = await this.isInstalled(code) if (isInstalled) { return this.getFeature(code) } return false }) ) return installed.filter((f) => !!f) as FeatureMap[keyof FeatureMap][] } } export default interface FeatureInstaller { isInstalled(code: FeatureCode): Promise<boolean> markAsSkippedThisRun(code: FeatureCode): void markAsPermanentlySkipped(code: FeatureCode): void isMarkedAsSkipped(code: FeatureCode): boolean getFeature<C extends FeatureCode>(code: C): FeatureMap[C] areInstalled(codes: FeatureCode[]): Promise<boolean> install(options: InstallFeatureOptions): Promise<FeatureInstallResponse> getInstalledFeatures(): Promise<FeatureMap[keyof FeatureMap][]> getFeatureDependencies<C extends FeatureCode>( featureCode: C ): FeatureDependency[] getAllCodes(): FeatureCode[] mapFeature<C extends FeatureCode>(code: C, feature: FeatureMap[C]): void isInSpruceModule(): boolean }