@sprucelabs/spruce-cli
Version:
Command line interface for building Spruce skills.
682 lines (585 loc) • 20.9 kB
text/typescript
import path from 'path'
import AbstractSpruceError from '@sprucelabs/error'
import globby from '@sprucelabs/globby'
import {
FieldFactory,
FieldDefinitionValueType,
areSchemaValuesValid,
} from '@sprucelabs/schema'
import { IField } from '@sprucelabs/schema'
import { diskUtil, namesUtil } from '@sprucelabs/spruce-skill-utils'
// @ts-ignore
import fonts from 'cfonts'
import chalk from 'chalk'
// @ts-ignore No definition available
import Table from 'cli-table3'
// @ts-ignore No definition available
import emphasize from 'emphasize'
import fs from 'fs-extra'
import inquirer from 'inquirer'
import _ from 'lodash'
import { filter } from 'lodash'
import ora from 'ora'
import { terminal } from 'terminal-kit'
import { ProgressBarController } from 'terminal-kit/Terminal'
import { FieldDefinitions } from '#spruce/schemas/fields/fields.types'
import SpruceError from '../errors/SpruceError'
import featuresUtil from '../features/feature.utilities'
import { ExecutionResults, GraphicsInterface } from '../types/cli.types'
import {
GraphicsTextEffect,
ImageDimensions,
ProgressBarOptions,
ProgressBarUpdateOptions,
} from '../types/graphicsInterface.types'
import durationUtil from '../utilities/duration.utility'
import isCi from '../utilities/isCi'
let fieldCount = 0
function generateInquirerFieldName() {
fieldCount++
return `field-${fieldCount}`
}
/** Remove effects cfonts does not support */
function filterEffectsForCFonts(effects: GraphicsTextEffect[]) {
return filter(
effects,
(effect) =>
[
GraphicsTextEffect.SpruceHeader,
GraphicsTextEffect.Reset,
GraphicsTextEffect.Bold,
GraphicsTextEffect.Dim,
GraphicsTextEffect.Italic,
GraphicsTextEffect.Underline,
GraphicsTextEffect.Inverse,
GraphicsTextEffect.Hidden,
GraphicsTextEffect.Strikethrough,
GraphicsTextEffect.Visible,
].indexOf(effect) === -1
)
}
interface TerminalSpecificOptions {
eraseBeforeRender?: boolean
}
export default class TerminalInterface implements GraphicsInterface {
private static loader?: ora.Ora | null
private static _doesSupportColor = process?.stdout?.isTTY && !isCi()
public static ora = ora
public isPromptActive = false
public cwd: string
private renderStackTraces = false
private progressBar: ProgressBarController | null = null
private log: (...args: any[]) => void
public constructor(
cwd: string,
renderStackTraces = false,
log = console.log.bind(console)
) {
this.cwd = cwd
this.renderStackTraces = renderStackTraces
this.log = log
}
public static doesSupportColor() {
return this._doesSupportColor
}
public static setDoesSupportColor(isTTy: boolean) {
this._doesSupportColor = isTTy
}
public async sendInput(): Promise<void> {
throw new Error('sendInput not supported on the TerminalInterface!')
}
public renderLines(lines: any[], effects?: GraphicsTextEffect[]) {
lines.forEach((line) => {
this.renderLine(line, effects)
})
}
public renderObject(
object: Record<string, any>,
effects: GraphicsTextEffect[] = [GraphicsTextEffect.Green]
) {
this.renderLine('')
this.renderDivider()
this.renderLine('')
Object.keys(object).forEach((key) => {
this.renderLine(
`${chalk.bold(key)}: ${
typeof object[key] === 'string'
? object[key]
: JSON.stringify(object[key])
}`,
effects
)
})
this.renderLine('')
this.renderDivider()
this.renderLine('')
}
public renderSection(options: {
headline?: string
lines?: string[]
object?: Record<string, any>
headlineEffects?: GraphicsTextEffect[]
bodyEffects?: GraphicsTextEffect[]
dividerEffects?: GraphicsTextEffect[]
}) {
const {
headline,
lines,
object,
dividerEffects = [],
headlineEffects = [
GraphicsTextEffect.Blue,
GraphicsTextEffect.Bold,
],
bodyEffects = [GraphicsTextEffect.Green],
} = options
if (headline) {
this.renderHeadline(
`${headline} 🌲🤖`,
headlineEffects,
dividerEffects
)
}
if (lines) {
this.renderLines(lines, bodyEffects)
this.renderLine('')
this.renderDivider(dividerEffects)
}
if (object) {
this.renderObject(object, bodyEffects)
}
this.renderLine('')
}
public renderDivider(effects?: GraphicsTextEffect[]) {
const bar = '=================================================='
this.renderLine(bar, effects)
}
public renderActionSummary(
results: ExecutionResults & { totalTime?: number }
) {
const generatedFiles =
results.files?.filter((f) => f.action === 'generated') ?? []
const updatedFiles =
results.files?.filter((f) => f.action === 'updated') ?? []
const skippedFiles =
results.files?.filter((f) => f.action === 'skipped') ?? []
const errors = results.errors ?? []
const packagesInstalled = results.packagesInstalled ?? []
const namespace = results.namespace
this.renderHero(`${results.headline}`)
let summaryLines: string[] = [
namespace ? `Namespace: ${namespace}` : null,
errors.length > 0 ? `Errors: ${errors.length}` : null,
generatedFiles.length > 0
? `Generated files: ${generatedFiles.length}`
: null,
updatedFiles.length > 0
? `Updated files: ${updatedFiles.length}`
: null,
skippedFiles.length > 0
? `Skipped files: ${skippedFiles.length}`
: null,
packagesInstalled.length > 0
? `NPM packages installed: ${packagesInstalled.length}`
: null,
...(results.summaryLines ?? []),
].filter((line) => !!line) as string[]
if (summaryLines.length === 0) {
summaryLines.push('Nothing to report!')
}
this.renderSection({
headline: `${featuresUtil.generateCommand(
results.featureCode,
results.actionCode
)} summary`,
lines: summaryLines,
})
if (packagesInstalled.length > 0) {
const table = new Table({
head: ['Name', 'Dev'],
colWidths: [40, 5],
wordWrap: true,
colAligns: ['left', 'center'],
})
packagesInstalled
.sort((one, two) => (one.name > two.name ? 1 : -1))
.forEach((pkg) => {
table.push([pkg.name, pkg.isDev ? '√' : ''])
})
this.renderSection({
headline: `NPM packages summary`,
lines: [table.toString()],
})
}
for (let files of [generatedFiles, updatedFiles]) {
if (files.length > 0) {
const table = new Table({
head: ['File', 'Description'],
wordWrap: true,
})
files = files.sort()
for (const file of files) {
table.push([file.name, file.description ?? ''])
}
this.renderSection({
headline: `${namesUtil.toPascal(files[0].action)} file summary`,
lines: [table.toString()],
})
}
}
if (results.hints) {
this.renderSection({
headline: 'Read below 👇',
lines: results.hints,
})
}
if (errors.length > 0) {
this.renderHeadline('Errors')
errors.forEach((err) => this.renderError(err))
}
if (results.totalTime) {
this.renderLine(
`Total time: ${durationUtil.msToFriendly(results.totalTime)}`
)
}
}
public renderHeadline(
message: string,
effects: GraphicsTextEffect[] = [
GraphicsTextEffect.Blue,
GraphicsTextEffect.Bold,
],
dividerEffects: GraphicsTextEffect[] = []
) {
const isSpruce = effects.indexOf(GraphicsTextEffect.SpruceHeader) > -1
if (isSpruce && TerminalInterface.doesSupportColor()) {
fonts.say(message, {
font: GraphicsTextEffect.SpruceHeader,
align: 'left',
space: false,
colors: filterEffectsForCFonts(effects),
})
} else {
this.renderDivider(dividerEffects)
this.renderLine(message, effects)
this.renderDivider(dividerEffects)
this.renderLine('')
}
}
public setTitle(title: string): void {
process.stdout.write('\x1b]2;' + title + '\x07')
}
public renderHero(message: string, effects?: GraphicsTextEffect[]) {
if (!TerminalInterface.doesSupportColor()) {
this.renderLine(message)
return
}
const shouldStripVowels = process.stdout.columns < 80
let stripped = shouldStripVowels
? message.replace(/[aeiou]/gi, '')
: message
if (
shouldStripVowels &&
['a', 'e', 'i', 'o', 'u'].indexOf(message[0].toLowerCase()) > -1
) {
stripped = `${message[0]}${stripped}`
}
fonts.say(stripped, {
align: 'left',
gradient: [GraphicsTextEffect.Red, GraphicsTextEffect.Blue],
colors: effects ? filterEffectsForCFonts(effects) : undefined,
})
}
public renderHint(message: string) {
return this.renderLine(`👨🏫 ${message}`)
}
public renderLine(
message: any,
effects: GraphicsTextEffect[] = [],
options?: TerminalSpecificOptions
) {
let write: any = chalk
effects.forEach((effect) => {
write = write[effect]
})
if (options?.eraseBeforeRender) {
terminal.eraseLine()
}
this.log(effects.length > 0 ? write(message) : message)
}
public renderWarning(message: string) {
this.renderLine(`⚠️ ${message}`, [
GraphicsTextEffect.Bold,
GraphicsTextEffect.Yellow,
])
}
public async startLoading(message?: string) {
this.stopLoading()
if (!this.isPromptActive) {
TerminalInterface.loader = TerminalInterface.ora({
text: message,
}).start()
}
}
public stopLoading() {
TerminalInterface.loader?.stop()
TerminalInterface.loader = null
}
public async confirm(question: string): Promise<boolean> {
const confirmResult = await inquirer.prompt({
type: 'confirm',
name: 'answer',
message: question,
})
return !!confirmResult.answer
}
public async waitForEnter(message?: string) {
await this.prompt({
type: 'text',
label: `${message ? message + ' ' : ''}${chalk.bgGreenBright.black(
'hit enter'
)}`,
})
this.renderLine('')
return
}
public clear() {
void this.stopLoading()
console.clear()
}
public renderCodeSample(code: string) {
try {
const colored = emphasize.highlight('js', code).value
this.renderLine(colored)
} catch (err: any) {
this.renderWarning(err)
}
}
public async prompt<T extends FieldDefinitions>(
definition: T
): Promise<FieldDefinitionValueType<T>> {
this.isPromptActive = true
if (isCi()) {
throw new SpruceError({ code: 'CANNOT_PROMPT_IN_CI' })
}
const name = generateInquirerFieldName()
const fieldDefinition: FieldDefinitions = definition
const { defaultValue } = fieldDefinition
const promptOptions: Record<string, any> = {
default: defaultValue,
name,
message: this.generatePromptLabel(fieldDefinition),
}
const field = FieldFactory.Field('prompt', fieldDefinition)
promptOptions.transformer = (value: string) => {
return (field as IField<any>).toValueType(value)
}
promptOptions.validate = (value: string) => {
return areSchemaValuesValid(
{
id: 'promptvalidateschema',
fields: {
prompt: fieldDefinition,
},
},
{ prompt: value }
)
// return field.validate(value, {}).length === 0
}
switch (fieldDefinition.type) {
// Map select options to prompt list choices
case 'boolean':
promptOptions.type = 'confirm'
break
case 'select':
promptOptions.type = fieldDefinition.isArray
? 'checkbox'
: 'list'
promptOptions.choices = fieldDefinition.options.choices.map(
// @ts-ignore
(choice) => ({
name: choice.label,
value: choice.value,
checked: _.includes(
fieldDefinition.defaultValue,
choice.value
),
})
)
break
// Directory select
// File select
case 'directory': {
if (fieldDefinition.isArray) {
throw new SpruceError({
code: 'NOT_IMPLEMENTED',
friendlyMessage:
'isArray file field not supported, prompt needs to be rewritten with isArray support',
})
}
const dirPath = path.join(
fieldDefinition.defaultValue?.path ?? this.cwd,
'/'
)
promptOptions.type = 'file'
promptOptions.root = dirPath
promptOptions.onlyShowDir = true
// Only let people select an actual file
promptOptions.validate = (value: string) => {
return (
diskUtil.doesDirExist(value) &&
fs.lstatSync(value).isDirectory()
)
}
// Strip out cwd from the paths while selecting
promptOptions.transformer = (path: string) => {
const cleanedPath = path.replace(promptOptions.root, '')
return cleanedPath.length === 0
? promptOptions.root
: cleanedPath
}
break
}
case 'file': {
if (fieldDefinition.isArray) {
throw new SpruceError({
code: 'NOT_IMPLEMENTED',
friendlyMessage:
'isArray file field not supported, prompt needs to be rewritten with isArray support',
})
}
const dirPath = path.join(
fieldDefinition.defaultValue?.uri ?? this.cwd,
'/'
)
// Check if directory is empty.
const files = await globby(`${dirPath}**/*`)
if (files.length === 0) {
throw new SpruceError({
code: 'DIRECTORY_EMPTY',
directory: dirPath,
friendlyMessage: `I wanted to help you select a file, but none exist in ${dirPath}.`,
})
}
promptOptions.type = 'file'
promptOptions.root = dirPath
// Only let people select an actual file
promptOptions.validate = (value: string) => {
return (
diskUtil.doesDirExist(value) &&
!fs.lstatSync(value).isDirectory() &&
path.extname(value) === '.ts'
)
}
// Strip out cwd from the paths while selecting
promptOptions.transformer = (path: string) => {
const cleanedPath = path.replace(promptOptions.root, '')
return cleanedPath.length === 0
? promptOptions.root
: cleanedPath
}
break
}
// Defaults to input
default:
promptOptions.type = 'input'
}
const response = (await inquirer.prompt(promptOptions)) as any
this.isPromptActive = false
const result =
typeof response[name] !== 'undefined'
? (field as IField<any>).toValueType(response[name])
: response[name]
return result
}
private generatePromptLabel(fieldDefinition: FieldDefinitions): any {
let label = fieldDefinition.label
if (fieldDefinition.hint) {
label = `${label} ${chalk.italic.dim(`(${fieldDefinition.hint})`)}`
}
label = label + ': '
return label
}
public renderError(err: Error) {
this.stopLoading()
const message = err.message
// Remove message from stack so the message is not doubled up
const stackLines = this.cleanStack(err)
this.renderSection({
headline: message,
lines: this.renderStackTraces
? stackLines.splice(0, 100)
: undefined,
headlineEffects: [GraphicsTextEffect.Bold, GraphicsTextEffect.Red],
dividerEffects: [GraphicsTextEffect.Red],
bodyEffects: [GraphicsTextEffect.Red],
})
}
private cleanStack(err: Error) {
const message = err.message
let stack = err.stack ? err.stack.replace(message, '') : ''
if (err instanceof AbstractSpruceError) {
let original = err.originalError
while (original) {
stack = stack.replace('Error: ' + original.message, '')
original = (original as AbstractSpruceError).originalError
}
}
const stackLines = stack.split('\n')
return stackLines
}
public renderProgressBar(options: ProgressBarOptions): void {
this.removeProgressBar()
this.progressBar = terminal.progressBar({
...options,
percent: options.showPercent,
eta: options.showEta,
items: options.totalItems,
inline: options.renderInline,
})
}
public removeProgressBar() {
if (this.progressBar) {
this.progressBar.stop()
this.progressBar = null
}
}
public updateProgressBar(options: ProgressBarUpdateOptions): void {
if (this.progressBar) {
this.progressBar.update({
...options,
items: options.totalItems,
})
}
}
public async renderImage(
_path: string,
_options?: ImageDimensions
): Promise<void> {
// const image = await terminalImage.file(path, options)
this.renderLine('Images not supported....')
}
public async getCursorPosition(): Promise<{ x: number; y: number } | null> {
return new Promise((resolve) => {
terminal.requestCursorLocation()
terminal.getCursorLocation((err, x, y) => {
resolve(err ? null : { x: x ?? 0, y: y ?? 0 })
})
})
}
public saveCursor() {
terminal.saveCursor()
}
public restoreCursor() {
terminal.restoreCursor()
}
public moveCursorTo(x: number, y: number): void {
terminal.moveTo(x, y)
}
public clearBelowCursor(): void {
terminal.eraseDisplayBelow()
}
public eraseLine() {
terminal.eraseLine()
}
}