@sprucelabs/spruce-cli
Version:
Command line interface for building Spruce skills.
397 lines (337 loc) • 11.8 kB
text/typescript
import osUtil from 'os'
import {
ConnectionOptions,
MercuryClientFactory,
} from '@sprucelabs/mercury-client'
import { SpruceSchemas } from '@sprucelabs/mercury-types'
import {
HealthCheckResults,
HEALTH_DIVIDER,
} from '@sprucelabs/spruce-skill-utils'
import { templates } from '@sprucelabs/spruce-templates'
import { DEFAULT_HOST } from '../constants'
import SpruceError from '../errors/SpruceError'
import ActionExecuter from '../features/ActionExecuter'
import ActionFactory from '../features/ActionFactory'
import FeatureCommandAttacher, {
BlockedCommands,
OptionOverrides,
} from '../features/FeatureCommandAttacher'
import FeatureInstaller from '../features/FeatureInstaller'
import FeatureInstallerFactory from '../features/FeatureInstallerFactory'
import { FeatureCode, InstallFeatureOptions } from '../features/features.types'
import CliGlobalEmitter, { GlobalEmitter } from '../GlobalEmitter'
import TerminalInterface from '../interfaces/TerminalInterface'
import ImportService from '../services/ImportService'
import PkgService from '../services/PkgService'
import ServiceFactory from '../services/ServiceFactory'
import StoreFactory from '../stores/StoreFactory'
import {
ApiClient,
ApiClientFactory,
ApiClientFactoryOptions,
} from '../types/apiClient.types'
import {
CliBootOptions,
CliInterface,
GraphicsInterface,
HealthOptions,
PromiseCache,
} from '../types/cli.types'
import apiClientUtil from '../utilities/apiClient.utility'
import { argParserUtil } from '../utilities/argParser.utility'
import WriterFactory from '../writers/WriterFactory'
export default class Cli implements CliInterface {
private cwd: string
private featureInstaller: FeatureInstaller
private serviceFactory: ServiceFactory
public readonly emitter: GlobalEmitter
private static apiClients: PromiseCache = {}
private attacher?: FeatureCommandAttacher
private actionExecuter?: ActionExecuter
private constructor(
cwd: string,
featureInstaller: FeatureInstaller,
serviceFactory: ServiceFactory,
emitter: GlobalEmitter,
attacher?: FeatureCommandAttacher,
actionExecuter?: ActionExecuter
) {
this.cwd = cwd
this.featureInstaller = featureInstaller
this.serviceFactory = serviceFactory
this.emitter = emitter
this.attacher = attacher
this.actionExecuter = actionExecuter
}
public static async resetApiClients() {
for (const key in this.apiClients) {
await (await this.apiClients[key]).disconnect()
}
this.apiClients = {}
}
public getAttacher() {
return this.attacher
}
public getActionExecuter() {
return this.actionExecuter
}
public async on(...args: any[]) {
//@ts-ignore
return this.emitter.on(...args)
}
public async off(...args: any[]) {
//@ts-ignore
return this.emitter.off(...args)
}
public async emit(...args: any[]) {
//@ts-ignore
return this.emitter.emit(...args)
}
public async emitAndFlattenResponses(...args: any[]) {
//@ts-ignore
return this.emitter.emitAndFlattenResponses(...args)
}
public async installFeatures(options: InstallFeatureOptions) {
return this.featureInstaller.install(options)
}
public getFeature<C extends FeatureCode>(code: C) {
return this.featureInstaller.getFeature(code)
}
public async checkHealth(
options?: HealthOptions
): Promise<HealthCheckResults> {
const isInstalled = await this.featureInstaller.isInstalled('skill')
if (!isInstalled) {
return {
skill: {
status: 'failed',
errors: [
new SpruceError({
// @ts-ignore
code: 'SKILL_NOT_INSTALLED',
}),
],
},
}
}
try {
const commandService = this.serviceFactory.Service(
this.cwd,
'command'
)
const command =
options?.shouldRunOnSourceFiles === false
? 'yarn health'
: 'yarn health.local'
const results = await commandService.execute(command)
const resultParts = results.stdout.split(HEALTH_DIVIDER)
return JSON.parse(resultParts[1]) as HealthCheckResults
} catch (originalError: any) {
const error = new SpruceError({
code: 'BOOT_ERROR',
originalError,
})
return {
skill: {
status: 'failed',
errors: [error],
},
}
}
}
public static async Boot(options?: CliBootOptions): Promise<CliInterface> {
const program = options?.program
const emitter = options?.emitter ?? CliGlobalEmitter.Emitter()
let cwd = options?.cwd ?? process.cwd()
ImportService.enableCaching()
const services = new ServiceFactory()
const apiClientFactory =
options?.apiClientFactory ??
Cli.buildApiClientFactory(cwd, services, options)
const storeFactory = new StoreFactory({
cwd,
serviceFactory: services,
homeDir: options?.homeDir ?? osUtil.homedir(),
emitter,
apiClientFactory,
})
const ui = (options?.graphicsInterface ??
new TerminalInterface(cwd)) as GraphicsInterface
let featureInstaller: FeatureInstaller | undefined
const writerFactory = new WriterFactory({
templates,
ui,
settings: services.Service(cwd, 'settings'),
linter: services.Service(cwd, 'lint'),
})
const pkg = services.Service(cwd, 'pkg')
const optionOverrides = this.loadOptionOverrides(pkg)
const blockedCommands = this.loadCommandBlocks(
services.Service(cwd, 'pkg')
)
try {
const s = pkg.getSkillNamespace()
if (s) {
ui.setTitle(s)
}
} catch {
// no skill
}
const actionFactory = new ActionFactory({
ui,
emitter,
apiClientFactory,
cwd,
serviceFactory: services,
storeFactory,
templates,
writerFactory,
blockedCommands,
optionOverrides,
})
const actionExecuter = new ActionExecuter({
actionFactory,
ui,
emitter,
featureInstallerFactory: () => featureInstaller!,
})
featureInstaller =
options?.featureInstaller ??
FeatureInstallerFactory.WithAllFeatures({
cwd,
serviceFactory: services,
storeFactory,
ui,
emitter,
apiClientFactory,
actionExecuter,
})
let attacher: FeatureCommandAttacher | undefined
if (program) {
attacher = new FeatureCommandAttacher({
pkgService: pkg,
program,
ui,
actionExecuter,
})
const codes = FeatureInstallerFactory.featureCodes
for (const code of codes) {
const feature = featureInstaller.getFeature(code)
await attacher.attachFeature(feature)
}
program.commands.sort((a: any, b: any) =>
a._name.localeCompare(b._name)
)
program.action((_, command) => {
throw new SpruceError({
code: 'INVALID_COMMAND',
args: command.args || [],
})
})
}
const cli = new Cli(
cwd,
featureInstaller,
services,
emitter,
attacher,
actionExecuter
)
return cli as CliInterface
}
private static loadCommandBlocks(pkg: PkgService): BlockedCommands {
let blocks: BlockedCommands = {}
if (pkg.doesExist()) {
blocks = pkg.get('skill.blockedCommands') ?? {}
}
return blocks
}
private static loadOptionOverrides(pkg: PkgService): OptionOverrides {
const mapped: OptionOverrides = {}
if (pkg.doesExist()) {
const overrides = pkg.get('skill.commandOverrides')
Object.keys(overrides ?? {}).forEach((command) => {
const options = argParserUtil.parse(overrides[command])
mapped[command] = options
})
}
return mapped
}
public static buildApiClientFactory(
cwd: string,
serviceFactory: ServiceFactory,
bootOptions?: CliBootOptions & ConnectionOptions
): ApiClientFactory {
const apiClientFactory = async (options?: ApiClientFactoryOptions) => {
const key = apiClientUtil.generateClientCacheKey(options)
if (!Cli.apiClients[key]) {
Cli.apiClients[key] = Cli.connectToApi(
cwd,
serviceFactory,
options,
bootOptions
)
}
return Cli.apiClients[key]
}
return apiClientFactory
}
private static async connectToApi(
cwd: string,
serviceFactory: ServiceFactory,
options?: ApiClientFactoryOptions,
bootOptions?: CliBootOptions & ConnectionOptions
): Promise<ApiClient> {
const connect = bootOptions?.apiClientFactory
? bootOptions.apiClientFactory
: async () => {
const eventsContracts =
require('#spruce/events/events.contract').default
const client: ApiClient = await MercuryClientFactory.Client({
contracts: eventsContracts as any,
host: bootOptions?.host ?? DEFAULT_HOST,
allowSelfSignedCrt: true,
...bootOptions,
})
return client
}
const {
shouldAuthAsCurrentSkill = false,
shouldAuthAsLoggedInPerson = true,
} = options ?? {}
const client = await connect()
let auth: SpruceSchemas.Mercury.v2020_12_25.AuthenticateEmitPayload = {}
const pkg = serviceFactory.Service(cwd, 'pkg')
const doesPkgExist = pkg.doesExist()
if (options?.skillId && options?.apiKey) {
auth = {
skillId: options.skillId,
apiKey: options.apiKey,
}
} else if (shouldAuthAsCurrentSkill) {
const skill = serviceFactory.Service(cwd, 'auth').getCurrentSkill()
if (skill) {
auth = {
skillId: skill.id,
apiKey: skill.apiKey,
}
}
} else if (doesPkgExist && shouldAuthAsLoggedInPerson) {
const person = serviceFactory
.Service(cwd, 'auth')
.getLoggedInPerson()
if (person) {
auth.token = person.token
}
}
if (Object.keys(auth).length > 0) {
await client.authenticate({
...(auth as any),
})
//@ts-ignore
client.auth = auth
}
return client
}
}