chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -0,0 +1,453 @@
import * as sdk from '@botpress/sdk'
import * as fslib from 'fs'
import * as pathlib from 'path'
import * as apiUtils from '../api'
import * as codegen from '../code-generation'
import type commandDefinitions from '../command-definitions'
import * as consts from '../consts'
import * as errors from '../errors'
import * as pkgRef from '../package-ref'
import * as utils from '../utils'
import { GlobalCommand } from './global-command'
import {
ProjectCache,
ProjectCommand,
ProjectCommandDefinition,
ProjectDefinitionLazy,
ProjectDefinition,
} from './project-command'
type InstallablePackage =
| {
type: 'integration'
pkg: codegen.IntegrationInstallablePackage
}
| {
type: 'interface'
pkg: codegen.InterfaceInstallablePackage
}
| {
type: 'plugin'
pkg: codegen.PluginInstallablePackage
}
type RefWithAlias = pkgRef.PackageRef & { alias?: string }
export type AddCommandDefinition = typeof commandDefinitions.add
export class AddCommand extends GlobalCommand<AddCommandDefinition> {
public async run(): Promise<void> {
const ref = this._parseArgvRef()
if (ref) {
return await this._addNewSinglePackage({ ...ref, alias: this.argv.alias })
}
const pkgJson = await utils.pkgJson.readPackageJson(this.argv.installPath).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Failed to read package.json file')
})
if (!pkgJson) {
this.logger.warn('No package.json found in the install path')
return
}
const { bpDependencies } = pkgJson
if (!bpDependencies) {
this.logger.log('No bp dependencies found in package.json')
return
}
const bpDependenciesSchema = sdk.z.record(sdk.z.string())
const parseResults = bpDependenciesSchema.safeParse(bpDependencies)
if (!parseResults.success) {
throw new errors.BotpressCLIError('Invalid bpDependencies found in package.json')
}
const baseInstallPath = utils.path.absoluteFrom(utils.path.cwd(), this.argv.installPath)
const modulesPath = utils.path.join(baseInstallPath, consts.installDirName)
fslib.rmSync(modulesPath, { force: true, recursive: true })
fslib.mkdirSync(modulesPath)
for (const [pkgAlias, pkgRefStr] of Object.entries(parseResults.data)) {
const parsed = pkgRef.parsePackageRef(pkgRefStr)
if (!parsed) {
throw new errors.InvalidPackageReferenceError(pkgRefStr)
}
// Resolve path refs against installPath so reinstall works regardless of cwd
const normalized =
parsed.type === 'path' ? { ...parsed, path: utils.path.absoluteFrom(baseInstallPath, parsed.path) } : parsed
const refWithAlias = { ...normalized, alias: pkgAlias }
const foundPkg = await this._findPackage(refWithAlias)
await this._addSinglePackage(refWithAlias, foundPkg)
}
}
private _parseArgvRef = (): pkgRef.PackageRef | undefined => {
if (!this.argv.packageRef) {
return
}
const parsed = pkgRef.parsePackageRef(this.argv.packageRef)
if (!parsed) {
throw new errors.InvalidPackageReferenceError(this.argv.packageRef)
}
return parsed
}
private async _addSinglePackage(
ref: RefWithAlias,
props: { packageName: string; targetPackage: InstallablePackage }
) {
const { packageName, targetPackage } = props
const baseInstallPath = utils.path.absoluteFrom(utils.path.cwd(), this.argv.installPath)
const packageDirName = utils.casing.to.kebabCase(packageName)
const installPath = utils.path.join(baseInstallPath, consts.installDirName, packageDirName)
const alreadyInstalled = fslib.existsSync(installPath)
if (alreadyInstalled) {
await this._uninstall(installPath)
}
if (ref.type === 'name') {
targetPackage.pkg.version = ref.version
}
let files: codegen.File[]
if (targetPackage.type === 'integration') {
files = await codegen.generateIntegrationPackage(targetPackage.pkg)
} else if (targetPackage.type === 'interface') {
files = await codegen.generateInterfacePackage(targetPackage.pkg)
} else if (targetPackage.type === 'plugin') {
files = await codegen.generatePluginPackage(targetPackage.pkg)
} else {
type _assertion = utils.types.AssertNever<typeof targetPackage>
throw new errors.BotpressCLIError('Invalid package type')
}
await this._install(installPath, files)
}
private async _chooseNewAlias(existingPackages: Record<string, string>) {
const setAliasConfirmation = await this.prompt.confirm(
'Do you want to set an alias to the package you are installing?'
)
if (!setAliasConfirmation) {
throw new errors.AbortedOperationError()
}
const alias = this._chooseUnusedAlias(existingPackages)
return alias
}
private async _chooseUnusedAlias(existingPackages: Record<string, string>): Promise<string> {
const alias = await this.prompt.text('Enter the new alias')
const existingAlias = Object.entries(existingPackages).find(([dep, _]) => dep === alias)
if (!alias) {
throw new errors.BotpressCLIError('You cannot set an empty alias')
}
if (!existingAlias) {
return alias
}
if (
await this.prompt.confirm(
`The alias ${alias} is already used for dependency ${existingAlias[1]}. Do you want to overwrite it?`
)
) {
return alias
}
return this._chooseUnusedAlias(existingPackages)
}
private async _addNewSinglePackage(ref: RefWithAlias) {
const foundPackage = await this._findPackage(ref)
const targetPackage = foundPackage.targetPackage
const isDevPackage = targetPackage.type === 'integration' && !!targetPackage.pkg.devId
let packageName: string
if (isDevPackage) {
this.logger.debug('Skipping bpDependencies update for dev integration')
packageName = foundPackage.packageName
} else {
packageName = await this._addDependencyToPackage(ref, foundPackage.packageName, targetPackage)
}
await this._addSinglePackage(ref, { packageName, targetPackage })
}
private async _findPackage(ref: RefWithAlias): Promise<{ packageName: string; targetPackage: InstallablePackage }> {
const targetPackage = ref.type === 'path' ? await this._findLocalPackage(ref) : await this._findRemotePackage(ref)
if (!targetPackage) {
const strRef = pkgRef.formatPackageRef(ref)
throw new errors.BotpressCLIError(`Could not find package "${strRef}"`)
}
const packageName = ref.alias ?? targetPackage.pkg.name
return { packageName, targetPackage }
}
private async _findRemotePackage(ref: pkgRef.ApiPackageRef): Promise<InstallablePackage | undefined> {
const api = await this.ensureLoginAndCreateClient(this.argv)
if (this._pkgCouldBe(ref, 'integration')) {
const integration = await api.findPublicOrPrivateIntegration(ref)
if (integration) {
const { name, version } = integration
return { type: 'integration', pkg: { integration, name, version } }
}
}
if (this._pkgCouldBe(ref, 'interface')) {
const intrface = await api.findPublicOrPrivateInterface(ref)
if (intrface) {
const { name, version } = intrface
return { type: 'interface', pkg: { interface: intrface, name, version } }
}
}
if (this._pkgCouldBe(ref, 'plugin')) {
const plugin = await api.findPublicOrPrivatePlugin(ref)
if (plugin) {
const { code } = plugin.public
? await api.client.getPublicPluginCode({ id: plugin.id, platform: 'node' })
: await api.client.getPluginCode({ id: plugin.id, platform: 'node' })
const { name, version } = plugin
return {
type: 'plugin',
pkg: {
name,
version,
plugin,
code,
},
}
}
}
return
}
private async _findLocalPackage(ref: pkgRef.LocalPackageRef): Promise<InstallablePackage | undefined> {
const absPath = utils.path.absoluteFrom(utils.path.cwd(), ref.path)
const {
definition: projectDefinition,
implementation: projectImplementation,
devId: projectDevId,
} = await this._readProject(absPath)
if (projectDefinition?.type === 'integration') {
const { name, version } = projectDefinition.definition
let devId: string | undefined
if (this.argv.useDev && projectDevId) {
this.logger.warn(`Installing integration "${name}" with dev version "${projectDevId}"`)
devId = projectDevId
}
let createIntegrationReqBody = await this._getProjectCmd(ref.path).prepareCreateIntegrationBody(
projectDefinition.definition
)
createIntegrationReqBody = {
...createIntegrationReqBody,
interfaces: utils.records.mapValues(projectDefinition.definition.interfaces ?? {}, (i) => ({
id: '', // TODO: do this better
...i,
})),
}
return {
type: 'integration',
pkg: { path: absPath, devId, name, version, integration: createIntegrationReqBody },
}
}
if (projectDefinition?.type === 'interface') {
const { name, version } = projectDefinition.definition
const createInterfaceReqBody = await apiUtils.prepareCreateInterfaceBody(projectDefinition.definition)
return {
type: 'interface',
pkg: { path: absPath, name, version, interface: createInterfaceReqBody },
}
}
if (projectDefinition?.type === 'plugin') {
if (!projectImplementation) {
throw new errors.BotpressCLIError(
'Plugin implementation not found; Please build the plugin project before installing'
)
}
const pluginDefinition = projectDefinition.definition
const { name, version } = pluginDefinition
const code = projectImplementation
const createPluginReqBody = await apiUtils.prepareCreatePluginBody(pluginDefinition)
return {
type: 'plugin',
pkg: {
path: absPath,
name,
version,
code,
plugin: {
...createPluginReqBody,
dependencies: {
interfaces: pluginDefinition.interfaces,
integrations: pluginDefinition.integrations,
},
recurringEvents: pluginDefinition.recurringEvents,
},
},
}
}
if (projectDefinition?.type === 'bot') {
throw new errors.BotpressCLIError('Cannot install a bot as a package')
}
return
}
private async _install(installPath: utils.path.AbsolutePath, files: codegen.File[]): Promise<void> {
const line = this.logger.line()
line.started(`Installing ${files.length} files to "${installPath}"`)
try {
for (const file of files) {
const filePath = utils.path.absoluteFrom(installPath, file.path)
const dirPath = pathlib.dirname(filePath)
await fslib.promises.mkdir(dirPath, { recursive: true })
await fslib.promises.writeFile(filePath, file.content)
}
line.success(`Installed ${files.length} files to "${installPath}"`)
} finally {
line.commit()
}
}
private async _uninstall(installPath: utils.path.AbsolutePath): Promise<void> {
await fslib.promises.rm(installPath, { recursive: true })
}
private async _readProject(workDir: utils.path.AbsolutePath): Promise<{
definition?: ProjectDefinition
implementation?: string
devId?: string
}> {
const cmd = this._getProjectCmd(workDir)
const { resolveProjectDefinition } = cmd.readProjectDefinitionFromFS()
const definition = await resolveProjectDefinition().catch((thrown) => {
if (thrown instanceof errors.ProjectDefinitionNotFoundError) {
return undefined
}
throw thrown
})
const devId = await cmd.projectCache.get('devId').catch((thrown) => {
const err = errors.BotpressCLIError.wrap(thrown, 'Failed to read project dev ID from cache')
this.logger.debug(err.message)
return undefined
})
const implementationAbsPath = utils.path.join(workDir, consts.fromWorkDir.outFileCJS)
if (!fslib.existsSync(implementationAbsPath)) {
return { definition, devId }
}
const implementation = await fslib.promises.readFile(implementationAbsPath, 'utf8')
return { definition, implementation, devId }
}
private _pkgCouldBe = (ref: pkgRef.ApiPackageRef, pkgType: InstallablePackage['type']) => {
if (ref.type === 'id') {
// TODO: use ULID prefixes to determine the type of the package
return true
}
if (!ref.pkg) {
return true // ref does not specify the package type
}
return ref.pkg === pkgType
}
private _getProjectCmd(workDir: string): _AnyProjectCommand {
return new _AnyProjectCommand(apiUtils.ApiClient, this.prompt, this.logger, {
...this.argv,
workDir,
})
}
private async _addDependencyToPackage(
ref: RefWithAlias,
packageName: string,
targetPackage: InstallablePackage
): Promise<string> {
const pkgJson = await utils.pkgJson.readPackageJson(this.argv.installPath).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Failed to read package.json file')
})
if (!pkgJson) {
this.logger.warn('No package.json found in the install path')
return packageName
}
const version =
ref.type === 'path'
? utils.path.relativePathFrom(
utils.path.absoluteFrom(utils.path.cwd(), this.argv.installPath),
utils.path.absoluteFrom(utils.path.cwd(), ref.path)
)
: `${targetPackage.type}:${targetPackage.pkg.name}@${targetPackage.pkg.version}`
const { bpDependencies } = pkgJson
if (!bpDependencies) {
pkgJson.bpDependencies = { [packageName]: version }
await utils.pkgJson.writePackageJson(this.argv.installPath, pkgJson)
return packageName
}
const bpDependenciesSchema = sdk.z.record(sdk.z.string())
const parseResult = bpDependenciesSchema.safeParse(bpDependencies)
if (!parseResult.success) {
throw new errors.BotpressCLIError('Invalid bpDependencies found in package.json')
}
const { data: validatedBpDeps } = parseResult
const alreadyPresentDep = Object.entries(validatedBpDeps).find(([key]) => key === packageName)
if (alreadyPresentDep) {
const alreadyPresentVersion = alreadyPresentDep[1]
if (alreadyPresentVersion !== version) {
this.logger.warn(
`The dependency with alias ${packageName} is already present in the bpDependencies of package.json, but with version ${alreadyPresentVersion}.`
)
const res = await this.prompt.confirm(`Do you want to overwrite the dependency with version ${version}?`)
if (!res) {
const newAlias = await this._chooseNewAlias(validatedBpDeps)
packageName = newAlias
}
}
}
pkgJson.bpDependencies = {
...validatedBpDeps,
[packageName]: version,
}
await utils.pkgJson.writePackageJson(this.argv.installPath, pkgJson)
return packageName
}
}
// this is a hack to avoid refactoring the project command class
class _AnyProjectCommand extends ProjectCommand<ProjectCommandDefinition> {
public async run(): Promise<void> {
throw new errors.BotpressCLIError('Not implemented')
}
public readProjectDefinitionFromFS(): ProjectDefinitionLazy {
return super.readProjectDefinitionFromFS()
}
public async prepareCreateIntegrationBody(
integrationDef: sdk.IntegrationDefinition
): Promise<apiUtils.CreateIntegrationRequestBody> {
return super.prepareCreateIntegrationBody(integrationDef)
}
public get projectCache(): utils.cache.FSKeyValueCache<ProjectCache> {
return super.projectCache
}
}
@@ -0,0 +1,41 @@
import * as errors from '../errors'
import type { Logger } from '../logger'
import type { CommandArgv, CommandDefinition } from '../typings'
export abstract class BaseCommand<C extends CommandDefinition> {
public constructor(
protected readonly logger: Logger,
protected readonly argv: CommandArgv<C>
) {}
protected abstract run(): Promise<void>
protected bootstrap?(): Promise<void>
protected teardown?(): Promise<void>
private get _cmdName(): string {
return this.constructor.name
}
public async handler(): Promise<{ exitCode: number }> {
let exitCode = 0
try {
if (this.bootstrap) {
await this.bootstrap()
}
await this.run()
} catch (thrown) {
const error = errors.BotpressCLIError.map(thrown)
this.logger.error(error.message)
this.logger.debug(`[${this._cmdName}] ${errors.BotpressCLIError.fullStack(error)}`)
exitCode = 1
} finally {
if (this.teardown) {
await this.teardown()
}
}
return { exitCode }
}
}
@@ -0,0 +1,89 @@
import chalk from 'chalk'
import { ApiClient } from '../api/client'
import type commandDefinitions from '../command-definitions'
import * as errors from '../errors'
import { GlobalCommand } from './global-command'
export type GetBotCommandDefinition = typeof commandDefinitions.bots.subcommands.get
export class GetBotCommand extends GlobalCommand<GetBotCommandDefinition> {
public async run(): Promise<void> {
const { client } = await this.ensureLoginAndCreateClient(this.argv)
try {
const { bot } = await client.getBot({ id: this.argv.botRef })
this.logger.success(`Bot ${chalk.bold(this.argv.botRef)}:`)
this.logger.json(bot)
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, `Could not get bot ${this.argv.botRef}`)
}
}
}
export type ListBotsCommandDefinition = typeof commandDefinitions.bots.subcommands.list
export class ListBotsCommand extends GlobalCommand<ListBotsCommandDefinition> {
public async run(): Promise<void> {
const api = await this.ensureLoginAndCreateClient(this.argv)
try {
const { dev } = this.argv
const bots = await api.listAllPages(
(x) => api.client.listBots({ ...x, dev }),
(r) => r.bots
)
this.logger.success('Bots:')
this.logger.json(bots)
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, 'Could not list bots')
}
}
}
export type DeleteBotCommandDefinition = typeof commandDefinitions.bots.subcommands.delete
export class DeleteBotCommand extends GlobalCommand<DeleteBotCommandDefinition> {
public async run(): Promise<void> {
const { client } = await this.ensureLoginAndCreateClient(this.argv)
try {
await client.deleteBot({ id: this.argv.botRef })
this.logger.success(`Bot ${chalk.bold(this.argv.botRef)} deleted`)
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, `Could not delete bot ${this.argv.botRef}`)
}
}
}
export type CreateBotCommandDefinition = typeof commandDefinitions.bots.subcommands.create
export class CreateBotCommand extends GlobalCommand<CreateBotCommandDefinition> {
public async run(): Promise<void> {
const api = await this.ensureLoginAndCreateClient(this.argv)
try {
if (this.argv.ifNotExists) {
await this._getOrCreate(api, this.argv.name)
return
}
await this._create(api, this.argv.name)
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, 'Could not create bot')
}
}
private _getOrCreate = async (api: ApiClient, name: string | undefined) => {
if (!name) {
throw new errors.BotpressCLIError('option --if-not-exists requires that a name be provided')
}
const existingBot = await api.findBotByName(name)
if (existingBot) {
this.logger.success(`Bot ${chalk.bold(name)} already exists`)
this.logger.json(existingBot)
return
}
return this._create(api, name)
}
private _create = async (api: ApiClient, name: string | undefined) => {
const { bot } = await api.client.createBot({ name })
this.logger.success(`Bot ${chalk.bold(bot.id)}:`)
this.logger.json(bot)
}
}
@@ -0,0 +1,36 @@
import type commandDefinitions from '../command-definitions'
import * as utils from '../utils'
import { BundleCommand } from './bundle-command'
import { GenerateCommand } from './gen-command'
import { ProjectCommand } from './project-command'
export type BuildCommandDefinition = typeof commandDefinitions.build
export class BuildCommand extends ProjectCommand<BuildCommandDefinition> {
public async run(buildContext?: utils.esbuild.BuildCodeContext): Promise<void> {
const t0 = Date.now()
const { projectType } = this.readProjectDefinitionFromFS()
if (projectType === 'interface') {
this.logger.success('Interface projects have nothing to build.')
return
}
await this._runGenerate()
await this._runBundle(buildContext)
const dt = Date.now() - t0
this.logger.log(`Build completed in ${dt}ms`)
}
private _runGenerate() {
return new GenerateCommand(this.api, this.prompt, this.logger, this.argv)
.setProjectContext(this.projectContext)
.run()
}
private _runBundle(buildContext?: utils.esbuild.BuildCodeContext) {
return new BundleCommand(this.api, this.prompt, this.logger, this.argv)
.setProjectContext(this.projectContext)
.run(buildContext)
}
}
@@ -0,0 +1,77 @@
import chalk from 'chalk'
import type commandDefinitions from '../command-definitions'
import * as errors from '../errors'
import * as utils from '../utils'
import { ProjectCommand } from './project-command'
export type BundleCommandDefinition = typeof commandDefinitions.bundle
export class BundleCommand extends ProjectCommand<BundleCommandDefinition> {
public async run(buildContext?: utils.esbuild.BuildCodeContext): Promise<void> {
const { projectType, resolveProjectDefinition } = this.readProjectDefinitionFromFS()
const abs = this.projectPaths.abs
const rel = this.projectPaths.rel('workDir')
const line = this.logger.line()
if (projectType === 'interface') {
this.logger.success('Interface projects have no implementation to bundle.')
} else if (projectType === 'integration') {
const projectDef = await resolveProjectDefinition()
const { name, __advanced } = projectDef.definition
line.started(`Bundling integration ${chalk.bold(name)}...`)
await this._bundle(abs.outFileCJS, buildContext, __advanced?.esbuild ?? {})
} else if (projectType === 'bot') {
line.started('Bundling bot...')
await this._bundle(abs.outFileCJS, buildContext)
} else if (projectType === 'plugin') {
line.started('Bundling plugin for node and browser platforms...')
await Promise.all([
this._bundle(abs.outFileCJS, buildContext),
this._bundle(abs.outFileESM, buildContext, { platform: 'browser', format: 'esm' }),
])
} else {
throw new errors.UnsupportedProjectType()
}
line.success(`Bundle available at ${chalk.grey(rel.outDir)}`)
}
private async _bundle(
outfile: string,
buildContext?: utils.esbuild.BuildCodeContext,
props: Partial<utils.esbuild.BuildOptions> = {}
) {
const abs = this.projectPaths.abs
const buildProps = {
outfile,
absWorkingDir: abs.workDir,
code: this._code,
}
const buildOptions = {
...this._buildOptions,
...props,
}
if (buildContext) {
await buildContext.rebuild(buildProps, buildOptions)
return
}
await utils.esbuild.buildCode(buildProps, buildOptions)
}
private get _code() {
const rel = this.projectPaths.rel('workDir')
const unixPath = utils.path.toUnix(rel.entryPoint)
const importFrom = utils.path.rmExtension(unixPath)
return `import x from './${importFrom}'; export default x; export const handler = x.handler;`
}
private get _buildOptions(): Partial<utils.esbuild.BuildOptions> {
return {
logLevel: this.argv.verbose ? 'info' : 'silent',
sourcemap: this.argv.sourceMap,
minify: this.argv.minify,
}
}
}
@@ -0,0 +1,158 @@
import * as chat from '@botpress/chat'
import * as client from '@botpress/client'
import semver from 'semver'
import { ApiClient } from '../api'
import { Chat } from '../chat'
import type commandDefinitions from '../command-definitions'
import * as consts from '../consts'
import * as errors from '../errors'
import * as utils from '../utils'
import { GlobalCommand } from './global-command'
type IntegrationInstance = {
alias: string
instance: client.Bot['integrations'][string]
}
export type ChatCommandDefinition = typeof commandDefinitions.chat
export class ChatCommand extends GlobalCommand<ChatCommandDefinition> {
public async run(): Promise<void> {
if (process.platform === 'win32') {
this.logger.warn('The chat command was not tested on Windows and may not work as expected')
}
const api = await this.ensureLoginAndCreateClient(this.argv)
const botId = this.argv.botId ?? (await this._selectBot(api))
const { bot } = await api.client.getBot({ id: botId }).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `Could not fetch bot "${botId}"`)
})
const targetChatVersion = this._getChatApiTargetVersionRange()
let chatIntegrationInstance = this._findChatInstance(bot)
if (!chatIntegrationInstance) {
this.logger.log(`Chat integration with version ${targetChatVersion} is not installed in the selected bot`)
const confirmInstall = await this.prompt.confirm('Do you wish to install it now?')
if (!confirmInstall) {
throw new errors.BotpressCLIError('Chat integration is required to proceed')
}
chatIntegrationInstance = await this._installChatIntegration(api, botId)
}
const { webhookId } = chatIntegrationInstance.instance
const chatApiBaseUrl = this._getChatApiUrl(api)
this.logger.debug(`using chat api url: "${chatApiBaseUrl}"`)
const chatApiUrl = `${chatApiBaseUrl}/${webhookId}`
const chatClient = await chat.Client.connect({ apiUrl: chatApiUrl })
await this._chat(chatClient)
}
private _chat = async (client: chat.AuthenticatedClient): Promise<void> => {
const convLine = this.logger.line()
convLine.started('Creating a conversation...')
const { conversation } = await client.createConversation({})
convLine.success(`Conversation created with id "${conversation.id}"`)
convLine.commit()
const chat = Chat.launch({ client, conversationId: conversation.id, protocol: this.argv.protocol })
await chat.wait()
}
private _getChatApiUrl = (api: ApiClient): string => {
if (this.argv.chatApiUrl) {
return this.argv.chatApiUrl
}
const parseResult = utils.url.parse(api.url)
if (parseResult.status === 'error') {
return consts.defaultChatApiUrl
}
const { host, ...url } = parseResult.url
if (!host.startsWith('api.')) {
return consts.defaultChatApiUrl
}
const newHost = host.replace('api.', 'chat.')
return utils.url.format({ ...url, host: newHost })
}
private _selectBot = async (api: ApiClient): Promise<string> => {
const availableBots = await api
.listAllPages(api.client.listBots, (r) => r.bots)
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Could not fetch existing bots')
})
if (!availableBots.length) {
throw new errors.NoBotsFoundError()
}
const prompted = await this.prompt.select('Which bot do you want to deploy?', {
choices: availableBots.map((bot) => ({ title: bot.name, value: bot.id })),
})
if (!prompted) {
throw new errors.ParamRequiredError('Bot Id')
}
return prompted
}
private _installChatIntegration = async (api: ApiClient, botId: string): Promise<IntegrationInstance> => {
const line = this.logger.line()
line.started('Installing chat integration...')
const { integration } = await api.client.getPublicIntegration({
name: 'chat',
version: this._getChatApiTargetVersion(),
})
const { bot } = await api.client.updateBot({
id: botId,
integrations: {
[integration.name]: {
integrationId: integration.id,
enabled: true,
configuration: {}, // empty object will always be a valid chat integration configuration
},
},
})
line.success('Chat integration installed')
line.commit()
const inst = this._findChatInstance(bot)
if (!inst) {
throw new errors.BotpressCLIError('Chat integration was installed but could not be found')
}
return inst
}
private _findChatInstance = (bot: client.Bot): IntegrationInstance | undefined => {
const integrationInstances = Object.entries(bot.integrations).map(([alias, integrationInstance]) => ({
alias,
instance: integrationInstance,
}))
const targetChatVersion = this._getChatApiTargetVersionRange()
return integrationInstances.find(
(i) =>
i.instance.name === 'chat' &&
(semver.satisfies(i.instance.version, targetChatVersion) || i.instance.version === 'dev')
)
}
private _getChatApiTargetVersionRange = (): string => {
const targetApiVersion = this._getChatApiTargetVersion()
const nextMajor = semver.inc(targetApiVersion, 'major')
return `>=${targetApiVersion} <${nextMajor}`
}
private _getChatApiTargetVersion = (): string => {
const dummyClient = new chat.Client({ apiUrl: '' })
return dummyClient.apiVersion
}
}
@@ -0,0 +1,550 @@
import * as client from '@botpress/client'
import * as sdk from '@botpress/sdk'
import chalk from 'chalk'
import * as fs from 'fs'
import semver from 'semver'
import * as apiUtils from '../api'
import type commandDefinitions from '../command-definitions'
import * as errors from '../errors'
import * as tables from '../tables'
import * as utils from '../utils'
import { BuildCommand } from './build-command'
import { ProjectCommand } from './project-command'
export type DeployCommandDefinition = typeof commandDefinitions.deploy
export class DeployCommand extends ProjectCommand<DeployCommandDefinition> {
public async run(): Promise<void> {
const api = await this.ensureLoginAndCreateClient(this.argv)
if (!this.argv.noBuild) {
await this._runBuild() // This ensures the bundle is always synced with source code
}
const { projectType, resolveProjectDefinition } = this.readProjectDefinitionFromFS()
if (projectType === 'integration') {
const projectDef = await resolveProjectDefinition()
return this._deployIntegration(api, projectDef.definition)
}
if (projectType === 'interface') {
const projectDef = await resolveProjectDefinition()
return this._deployInterface(api, projectDef.definition)
}
if (projectType === 'plugin') {
const projectDef = await resolveProjectDefinition()
return this._deployPlugin(api, projectDef.definition)
}
if (projectType === 'bot') {
const projectDef = await resolveProjectDefinition()
return this._deployBot(api, projectDef.definition, this.argv.botId, this.argv.createNewBot)
}
throw new errors.UnsupportedProjectType()
}
private async _runBuild() {
return new BuildCommand(this.api, this.prompt, this.logger, this.argv).setProjectContext(this.projectContext).run()
}
private get _visibility(): 'public' | 'private' | 'unlisted' {
if (this.argv.public && this.argv.visibility === 'private') {
this.logger.warn('The --public flag is deprecated. Please use "--visibility public" instead.')
return 'public'
}
if (this.argv.public && this.argv.visibility !== 'private') {
this.logger.warn('The --public flag and --visibility option are both present. Ignoring the --public flag...')
}
return this.argv.visibility
}
private async _deployIntegration(api: apiUtils.ApiClient, integrationDef: sdk.IntegrationDefinition) {
const res = await this.manageWorkspaceHandle(api, { type: 'integration', definition: integrationDef })
if (!res) return
const { definition: updatedIntegrationDef, workspaceId } = res
integrationDef = updatedIntegrationDef
if (workspaceId) {
api = api.switchWorkspace(workspaceId)
}
if (this.argv.bypassBreakingChangeDetection) {
api = api.withExtraHeaders({ 'x-bypass-breaking-changes-detection': 'true' })
}
const { name, version } = integrationDef
if (integrationDef.icon && !integrationDef.icon.toLowerCase().endsWith('.svg')) {
throw new errors.BotpressCLIError('Icon must be an SVG file')
}
if (integrationDef.readme && !integrationDef.readme.toLowerCase().endsWith('.md')) {
throw new errors.BotpressCLIError('Readme must be a Markdown file')
}
const integration = await api.findPublicOrPrivateIntegration({ type: 'name', name, version })
if (integration && integration.workspaceId !== api.workspaceId) {
throw new errors.BotpressCLIError(
`Public integration ${name} v${version} is already deployed in another workspace.`
)
}
if (integration && integration.visibility !== 'private' && !api.isBotpressWorkspace) {
this.logger.warn(
`Integration ${name} v${version} is already deployed publicly and cannot be updated. You should publish a new version instead.`
)
}
let message: string
if (integration) {
this.logger.warn('Integration already exists. If you decide to deploy, it will override the existing one.')
message = `Are you sure you want to override integration ${name} v${version}?`
} else {
message = `Are you sure you want to deploy integration ${name} v${version}?`
}
const confirm = await this.prompt.confirm(message)
if (!confirm) {
this.logger.log('Aborted')
return
}
this.logger.debug('Preparing integration request body...')
const createBody = {
...(await this.prepareCreateIntegrationBody(integrationDef)),
...(await this.prepareIntegrationDependencies(integrationDef, api)),
visibility: this._visibility,
sdkVersion: integrationDef.metadata?.sdkVersion,
url: this.argv.url,
}
const startedMessage = `Deploying integration ${chalk.bold(name)} v${version}...`
const successMessage = 'Integration deployed'
if (integration) {
const updateBody = apiUtils.prepareUpdateIntegrationBody(
{
id: integration.id,
...createBody,
},
integration
)
const { secrets: knownSecrets } = integration
updateBody.secrets = await this.promptSecrets(integrationDef, this.argv, { knownSecrets })
this._detectDeprecatedFeatures(integrationDef, { allowDeprecated: true })
const line = this.logger.line()
line.started(startedMessage)
if (this.argv.dryRun) {
this.logger.log('Dry-run mode is active. Simulating integration update...')
await api.client.validateIntegrationUpdate(updateBody).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `Could not update integration "${name}"`)
})
} else {
await api.client.updateIntegration(updateBody).catch((thrown) => {
const error = errors.BotpressCLIError.wrap(thrown, `Could not update integration "${name}"`)
if (
api.isBotpressWorkspace &&
!this.argv.bypassBreakingChangeDetection &&
client.isApiError(thrown) &&
thrown.type === 'BreakingChanges'
) {
this.logger.warn('Tip: redeploy with --bypassBreakingChangeDetection to skip this check')
}
throw error
})
}
line.success(successMessage)
} else {
this.logger.debug(`looking for previous version of integration "${name}"`)
const previousVersion = await api.findPreviousIntegrationVersion({ type: 'name', name, version })
if (previousVersion) {
this.logger.debug(`previous version found: ${previousVersion.version}`)
} else {
this.logger.debug('no previous version found')
}
const knownSecrets = previousVersion?.secrets
createBody.secrets = await this.promptSecrets(integrationDef, this.argv, { knownSecrets })
this._detectDeprecatedFeatures(integrationDef, {
allowDeprecated: this._allowDeprecatedFeatures(integrationDef, previousVersion),
})
const line = this.logger.line()
line.started(startedMessage)
if (this.argv.dryRun) {
this.logger.log('Dry-run mode is active. Simulating integration creation...')
await api.client.validateIntegrationCreation(createBody).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `Could not create integration "${name}"`)
})
} else {
await api.client.createIntegration(createBody).catch((thrown) => {
const error = errors.BotpressCLIError.wrap(thrown, `Could not create integration "${name}"`)
if (
api.isBotpressWorkspace &&
!this.argv.bypassBreakingChangeDetection &&
client.isApiError(thrown) &&
thrown.type === 'BreakingChanges'
) {
this.logger.warn('Tip: redeploy with --bypassBreakingChangeDetection to skip this check')
}
throw error
})
}
line.success(successMessage)
}
}
private async _deployInterface(api: apiUtils.ApiClient, interfaceDeclaration: sdk.InterfaceDefinition) {
if (this._visibility === 'unlisted') {
throw new errors.BotpressCLIError(
'Unlisted visibility is not supported for interfaces. Please use "public" or "private".'
)
}
if (interfaceDeclaration.icon && !interfaceDeclaration.icon.toLowerCase().endsWith('.svg')) {
throw new errors.BotpressCLIError('Icon must be an SVG file')
}
if (interfaceDeclaration.readme && !interfaceDeclaration.readme.toLowerCase().endsWith('.md')) {
throw new errors.BotpressCLIError('Readme must be a Markdown file')
}
const { name, version } = interfaceDeclaration
const intrface = await api.findPublicOrPrivateInterface({ type: 'name', name, version })
let message: string
if (intrface) {
this.logger.warn('Interface already exists. If you decide to deploy, it will override the existing one.')
message = `Are you sure you want to override interface ${name} v${version}?`
} else {
message = `Are you sure you want to deploy interface ${name} v${version}?`
}
const confirm = await this.prompt.confirm(message)
if (!confirm) {
this.logger.log('Aborted')
return
}
const icon = await this.readProjectFile(interfaceDeclaration.icon, 'base64')
const readme = await this.readProjectFile(interfaceDeclaration.readme, 'base64')
if (this._visibility !== 'public') {
this.logger.warn(
'You are currently publishing a private interface, which cannot be used by integrations and plugins. To fix this, change the visibility to "public"'
)
}
const createBody = {
...(await apiUtils.prepareCreateInterfaceBody(interfaceDeclaration)),
public: this._visibility === 'public',
icon,
readme,
sdkVersion: interfaceDeclaration.metadata?.sdkVersion,
}
const startedMessage = `Deploying interface ${chalk.bold(name)} v${version}...`
const successMessage = 'Interface deployed'
if (intrface) {
const updateBody = apiUtils.prepareUpdateInterfaceBody(
{
id: intrface.id,
...createBody,
},
intrface
)
const line = this.logger.line()
line.started(startedMessage)
if (this.argv.dryRun) {
this.logger.warn('Dry-run mode is not supported for interface updates. Skipping deployment...')
} else {
await api.client.updateInterface(updateBody).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `Could not update interface "${name}"`)
})
}
line.success(successMessage)
} else {
const line = this.logger.line()
line.started(startedMessage)
if (this.argv.dryRun) {
this.logger.warn('Dry-run mode is not supported for interface creation. Skipping deployment...')
} else {
await api.client.createInterface(createBody).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `Could not create interface "${name}"`)
})
}
line.success(successMessage)
}
}
private async _deployPlugin(api: apiUtils.ApiClient, pluginDef: sdk.PluginDefinition) {
const res = await this.manageWorkspaceHandle(api, { type: 'plugin', definition: pluginDef })
if (!res) return
const { definition: updatedPluginDef, workspaceId } = res
pluginDef = updatedPluginDef
if (workspaceId) {
api = api.switchWorkspace(workspaceId)
}
const codeCJS = await fs.promises.readFile(this.projectPaths.abs.outFileCJS, 'utf-8')
const codeESM = await fs.promises.readFile(this.projectPaths.abs.outFileESM, 'utf-8')
const { name, version } = pluginDef
if (pluginDef.icon && !pluginDef.icon.toLowerCase().endsWith('.svg')) {
throw new errors.BotpressCLIError('Icon must be an SVG file')
}
if (pluginDef.readme && !pluginDef.readme.toLowerCase().endsWith('.md')) {
throw new errors.BotpressCLIError('Readme must be a Markdown file')
}
const plugin = await api.findPublicOrPrivatePlugin({ type: 'name', name, version })
let message: string
if (plugin) {
this.logger.warn('Plugin already exists. If you decide to deploy, it will override the existing one.')
message = `Are you sure you want to override plugin ${name} v${version}?`
} else {
message = `Are you sure you want to deploy plugin ${name} v${version}?`
}
const confirm = await this.prompt.confirm(message)
if (!confirm) {
this.logger.log('Aborted')
return
}
this.logger.debug('Preparing plugin request body...')
const icon = await this.readProjectFile(pluginDef.icon, 'base64')
const readme = await this.readProjectFile(pluginDef.readme, 'base64')
const createBody = {
...(await apiUtils.prepareCreatePluginBody(pluginDef)),
...(await this.preparePluginDependencies(pluginDef, api)),
visibility: this._visibility,
icon,
readme,
code: {
node: codeCJS,
browser: codeESM,
},
sdkVersion: pluginDef.metadata?.sdkVersion,
}
const startedMessage = `Deploying plugin ${chalk.bold(name)} v${version}...`
const successMessage = 'Plugin deployed'
if (plugin) {
const updateBody = apiUtils.prepareUpdatePluginBody(
{
id: plugin.id,
...createBody,
},
plugin
)
const line = this.logger.line()
line.started(startedMessage)
if (this.argv.dryRun) {
this.logger.warn('Dry-run mode is not supported for plugin updates. Skipping deployment...')
} else {
await api.client.updatePlugin(updateBody).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `Could not update plugin "${name}"`)
})
}
line.success(successMessage)
} else {
const line = this.logger.line()
line.started(startedMessage)
if (this.argv.dryRun) {
this.logger.warn('Dry-run mode is not supported for plugin creation. Skipping deployment...')
} else {
await api.client.createPlugin(createBody).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `Could not create plugin "${name}"`)
})
}
line.success(successMessage)
}
}
private _allowDeprecatedFeatures(
integrationDef: sdk.IntegrationDefinition,
previousVersion: client.Integration | undefined
): boolean {
if (this.argv.allowDeprecated) {
return true
}
if (!previousVersion) {
return false
}
const versionDiff = semver.diff(integrationDef.version, previousVersion.version)
if (!versionDiff) {
return false
}
return utils.semver.releases.lt(versionDiff, 'major')
}
private _detectDeprecatedFeatures(
integrationDef: sdk.IntegrationDefinition,
opts: { allowDeprecated?: boolean } = {}
) {
const deprecatedFields: string[] = []
const { user, channels } = integrationDef
if (user?.creation?.enabled) {
deprecatedFields.push('user.creation')
}
for (const [channelName, channel] of Object.entries(channels ?? {})) {
if (channel?.conversation?.creation?.enabled) {
deprecatedFields.push(`channels.${channelName}.creation`)
}
}
if (!deprecatedFields.length) {
return
}
const errorMessage = `The following fields of the integration's definition are deprecated: ${deprecatedFields.join(
', '
)}`
if (opts.allowDeprecated) {
this.logger.warn(errorMessage)
} else {
throw new errors.BotpressCLIError(errorMessage)
}
}
private async _deployBot(
api: apiUtils.ApiClient,
botDefinition: sdk.BotDefinition,
argvBotId: string | undefined,
argvCreateNew: boolean | undefined
) {
if (this.argv.dryRun) {
this.logger.warn('Dry-run mode is not supported for bot deployments. Skipping deployment...')
return
}
const outfile = this.projectPaths.abs.outFileCJS
const code = await fs.promises.readFile(outfile, 'utf-8')
let bot: client.Bot
if (argvBotId && argvCreateNew) {
throw new errors.BotpressCLIError('Cannot specify both --botId and --createNew')
} else if (argvCreateNew) {
const confirm = await this.prompt.confirm('Are you sure you want to create a new bot ?')
if (!confirm) {
this.logger.log('Aborted')
return
}
bot = await this._createNewBot(api)
} else {
bot = await this._getExistingBot(api, argvBotId)
const confirm = await this.prompt.confirm(`Are you sure you want to deploy the bot "${bot.name}"?`)
if (!confirm) {
this.logger.log('Aborted')
return
}
}
const line = this.logger.line()
line.started(`Deploying bot ${chalk.bold(bot.name)}...`)
const updateBotBody = apiUtils.prepareUpdateBotBody(
{
...(await apiUtils.prepareCreateBotBody(botDefinition)),
...(await this.prepareBotDependencies(botDefinition, api)),
id: bot.id,
code,
},
bot
)
updateBotBody.secrets = await this.promptSecrets(botDefinition, this.argv, { knownSecrets: bot.secrets })
const { bot: updatedBot } = await api.client.updateBot(updateBotBody).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `Could not update bot "${bot.name}"`)
})
this.validateIntegrationRegistration(updatedBot, (failedIntegrations) =>
this.logger.warn(
`Some integrations failed to register:\n${Object.entries(failedIntegrations)
.map(([key, int]) => `${key}: ${int.statusReason}`)
.join('\n')}`
)
)
const tablesPublisher = new tables.TablesPublisher({ api, logger: this.logger, prompt: this.prompt })
await tablesPublisher.deployTables({ botId: updatedBot.id, botDefinition })
line.success('Bot deployed')
await this.displayIntegrationUrls({ api, bot: updatedBot })
}
private async _createNewBot(api: apiUtils.ApiClient): Promise<client.Bot> {
const line = this.logger.line()
const { bot: createdBot } = await api.client.createBot({}).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Could not create bot')
})
line.success(`Bot created with ID "${createdBot.id}" and name "${createdBot.name}"`)
await this.projectCache.set('botId', createdBot.id)
return createdBot
}
private async _getExistingBot(api: apiUtils.ApiClient, botId: string | undefined): Promise<client.Bot> {
const promptedBotId = await this.projectCache.sync('botId', botId, async (defaultId) => {
const userBots = await api
.listAllPages(api.client.listBots, (r) => r.bots)
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Could not fetch existing bots')
})
if (!userBots.length) {
throw new errors.NoBotsFoundError()
}
const initial = userBots.find((bot) => bot.id === defaultId)
const prompted = await this.prompt.select('Which bot do you want to deploy?', {
initial: initial && { title: initial.name, value: initial.id },
choices: userBots.map((bot) => ({ title: bot.name, value: bot.id })),
})
if (!prompted) {
throw new errors.ParamRequiredError('Bot Id')
}
return prompted
})
const { bot: fetchedBot } = await api.client.getBot({ id: promptedBotId }).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Could not get bot info')
})
return fetchedBot
}
}
@@ -0,0 +1,522 @@
import type * as client from '@botpress/client'
import * as sdk from '@botpress/sdk'
import { TunnelRequest, TunnelResponse } from '@bpinternal/tunnel'
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import chalk from 'chalk'
import { isEqual } from 'lodash'
import * as pathlib from 'path'
import * as uuid from 'uuid'
import * as apiUtils from '../api'
import { secretEnvVariableName, stripSecretEnvVariablePrefix } from '../code-generation/secret-module'
import type commandDefinitions from '../command-definitions'
import * as errors from '../errors'
import * as tables from '../tables'
import * as utils from '../utils'
import { Worker } from '../worker'
import { BuildCommand } from './build-command'
import { ProjectCommand, ProjectDefinition } from './project-command'
const DEFAULT_BOT_PORT = 8075
const DEFAULT_INTEGRATION_PORT = 8076
const TUNNEL_HELLO_INTERVAL = 5000
const FILEWATCHER_DEBOUNCE_MS = 500
export type DevCommandDefinition = typeof commandDefinitions.dev
export class DevCommand extends ProjectCommand<DevCommandDefinition> {
private _initialDef: ProjectDefinition | undefined = undefined
private _deployedIntegrationName: string | undefined = undefined
private _cacheDevRequestBody: apiUtils.UpdateBotRequestBody | apiUtils.UpdateIntegrationRequestBody | undefined
private _buildContext: utils.esbuild.BuildCodeContext
public constructor(...args: ConstructorParameters<typeof ProjectCommand<DevCommandDefinition>>) {
super(...args)
this._buildContext = new utils.esbuild.BuildCodeContext()
}
public async run(): Promise<void> {
this.logger.warn('This command is experimental and subject to breaking changes without notice.')
const watchEnabled = this.argv.watch !== false
let api = await this.ensureLoginAndCreateClient(this.argv)
const { projectType, resolveProjectDefinition } = this.readProjectDefinitionFromFS()
if (projectType === 'interface') {
throw new errors.BotpressCLIError('This feature is not available for interfaces.')
}
const projectDef = await resolveProjectDefinition()
this._initialDef = projectDef
if (projectDef.type === 'integration') {
const handleResult = await this.manageWorkspaceHandle(api, projectDef)
if (!handleResult) return
if (handleResult.workspaceId) {
api = api.switchWorkspace(handleResult.workspaceId)
}
this._deployedIntegrationName = handleResult.definition.name
}
let env: Record<string, string> = {
...process.env,
BP_API_URL: api.url,
BP_TOKEN: api.token,
}
const defaultPort = this._initialDef.type === 'integration' ? DEFAULT_INTEGRATION_PORT : DEFAULT_BOT_PORT
if (this._initialDef.type === 'integration' || this._initialDef.type === 'bot') {
const knownSecrets = await this._readKnownSecretsFromCache()
let secretEnvVariables = await this.promptSecrets(this._initialDef.definition, this.argv, {
knownSecrets: Object.keys(knownSecrets),
formatEnv: true,
})
secretEnvVariables = { ...this._applyPrefixToSecrets(knownSecrets), ...secretEnvVariables }
const nonNullSecretEnvVariables = utils.records.filterValues(secretEnvVariables, utils.guards.is.notNull)
if (!this.argv.noSecretCaching) {
await this._writeKnownSecretsToCache(secretEnvVariables)
}
env = { ...env, ...nonNullSecretEnvVariables }
}
const port = this.argv.port ?? defaultPort
const urlParseResult = utils.url.parse(this.argv.tunnelUrl)
if (urlParseResult.status === 'error') {
throw new errors.BotpressCLIError(`Invalid tunnel URL: ${urlParseResult.error}`)
}
const cachedTunnelId = await this.projectCache.get('tunnelId')
let tunnelId: string
if (this.argv.tunnelId) {
tunnelId = this.argv.tunnelId
} else if (cachedTunnelId) {
tunnelId = cachedTunnelId
} else {
tunnelId = uuid.v4()
}
if (cachedTunnelId !== tunnelId) {
await this.projectCache.set('tunnelId', tunnelId)
}
const { url: parsedTunnelUrl } = urlParseResult
const isSecured = parsedTunnelUrl.protocol === 'https' || parsedTunnelUrl.protocol === 'wss'
const wsTunnelUrl: string = utils.url.format({ ...parsedTunnelUrl, protocol: isSecured ? 'wss' : 'ws' })
const httpTunnelUrl: string = utils.url.format({
...parsedTunnelUrl,
protocol: isSecured ? 'https' : 'http',
path: `/${tunnelId}`,
})
let worker: Worker | undefined = undefined
const supervisor = new utils.tunnel.TunnelSupervisor(wsTunnelUrl, tunnelId, this.logger)
supervisor.events.on('connected', ({ tunnel }) => {
// prevents the tunnel from closing due to inactivity
const timer = setInterval(() => {
if (tunnel.closed) {
return handleClose()
}
tunnel.hello()
}, TUNNEL_HELLO_INTERVAL)
const handleClose = (): void => clearInterval(timer)
tunnel.events.on('close', handleClose)
tunnel.events.on('request', (req) => {
if (!worker) {
this.logger.debug('Worker not ready yet, ignoring request')
tunnel.send({ requestId: req.id, status: 503, body: 'Worker not ready yet' })
return
}
void this._forwardTunnelRequest(`http://localhost:${port}`, req)
.then((res) => {
tunnel.send(res)
})
.catch((thrown) => {
const err = errors.BotpressCLIError.wrap(
thrown,
`An error occurred while handling request ${req.method} ${req.path}`
)
this.logger.error(err.message)
this.logger.debug(errors.BotpressCLIError.fullStack(err))
tunnel.send({
requestId: req.id,
status: 500,
body: 'Internal error while handling request',
})
})
})
})
supervisor.events.on('manuallyClosed', () => {
this.logger.debug('Tunnel manually closed')
})
await supervisor.start()
await this._runBuild(watchEnabled)
worker = await this._spawnWorker(env, port)
try {
await this._deploy(api, httpTunnelUrl)
} catch (thrown) {
if (worker.running) {
await worker.kill()
}
throw errors.BotpressCLIError.wrap(thrown, 'An error occurred while deploying the dev server')
}
try {
let watcher: Awaited<ReturnType<typeof utils.filewatcher.FileWatcher.watch>> | undefined
if (!watchEnabled) {
await this._disposeBuildResources({ stopEsbuild: true })
await Promise.race([worker.wait(), supervisor.wait()])
} else {
watcher = await utils.filewatcher.FileWatcher.watch(
this.argv.workDir,
async (events) => {
if (!worker) {
this.logger.debug('Worker not ready yet, ignoring file change event')
return
}
const typescriptEvents = events
.filter((e) => !e.path.startsWith(this.projectPaths.abs.outDir))
.filter((e) => pathlib.extname(e.path) === '.ts')
const packageJsonEvents = events
.filter((e) => !e.path.startsWith(this.projectPaths.abs.outDir))
.filter((e) => pathlib.basename(e.path) === 'package.json')
const distEvents = events.filter((e) => e.path.startsWith(this.projectPaths.abs.distDir))
if (typescriptEvents.length > 0 || packageJsonEvents.length > 0) {
this.logger.log('Changes detected, rebuilding')
await this._restart(api, worker, httpTunnelUrl)
} else if (distEvents.length > 0) {
this.logger.log('Changes detected in output directory, reloading worker')
await worker.reload()
}
},
{
debounceMs: FILEWATCHER_DEBOUNCE_MS,
}
)
await Promise.race([worker.wait(), watcher.wait(), supervisor.wait()])
}
if (worker.running) {
await worker.kill()
}
await watcher?.close()
supervisor.close()
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, 'An error occurred while running the dev server')
} finally {
if (worker.running) {
await worker.kill()
}
await this._disposeBuildResources()
}
}
private _restart = async (api: apiUtils.ApiClient, worker: Worker, tunnelUrl: string) => {
try {
await this._runBuild()
} catch (thrown) {
const error = errors.BotpressCLIError.wrap(thrown, 'Build failed')
this.logger.error(error.message)
this.logger.debug(errors.BotpressCLIError.fullStack(error))
return
}
await worker.reload()
await this._deploy(api, tunnelUrl)
}
private _deploy = async (api: apiUtils.ApiClient, tunnelUrl: string) => {
const { projectType, resolveProjectDefinition } = this.readProjectDefinitionFromFS()
if (projectType === 'interface') {
throw new errors.BotpressCLIError('This feature is not available for interfaces.')
}
if (projectType === 'integration' && this._initialDef?.type === 'integration') {
const projectDef = await resolveProjectDefinition()
this._checkSecrets(projectDef.definition)
if (projectDef.definition.name !== this._initialDef.definition.name) {
throw new errors.BotpressCLIError(
`Integration name changed from "${this._initialDef.definition.name}" to "${projectDef.definition.name}". Renaming integrations during bp dev is not supported. Please restart bp dev.`
)
}
const integrationDef = new sdk.IntegrationDefinition({
...projectDef.definition,
name: this._deployedIntegrationName ?? this._initialDef.definition.name,
})
return await this._deployDevIntegration(api, tunnelUrl, integrationDef)
}
if (projectType === 'bot') {
const projectDef = await resolveProjectDefinition()
this._checkSecrets(projectDef.definition)
return await this._deployDevBot(api, tunnelUrl, projectDef.definition)
}
throw new errors.UnsupportedProjectType()
}
private async _writeKnownSecretsToCache(secretEnvVariables: Record<string, string | null>) {
const knownSecrets: Record<string, string | null> = {}
for (const [prefixedSecretName, secretValue] of Object.entries(secretEnvVariables)) {
const secretName = stripSecretEnvVariablePrefix(prefixedSecretName)
knownSecrets[secretName] = secretValue
}
const nonNullKnownSecrets = utils.records.filterValues(knownSecrets, utils.guards.is.notNull)
if (Object.keys(nonNullKnownSecrets).length === 0) {
await this.projectCache.rm('secrets')
return
}
await this.projectCache.set('secrets', nonNullKnownSecrets)
}
private async _readKnownSecretsFromCache() {
return (await this.projectCache.get('secrets')) ?? {}
}
private _applyPrefixToSecrets(secrets: Record<string, string>): Record<string, string> {
const prefixedSecretEntries = Object.entries(secrets).map(([secretName, secretValue]) => [
secretEnvVariableName(secretName),
secretValue,
])
return Object.fromEntries(prefixedSecretEntries)
}
private _checkSecrets(projectDef: sdk.IntegrationDefinition | sdk.BotDefinition) {
if (this._initialDef?.type !== 'integration' && this._initialDef?.type !== 'bot') {
return
}
const initialSecrets = this._initialDef?.definition.secrets ?? {}
const currentSecrets = projectDef.secrets ?? {}
const newSecrets = Object.keys(currentSecrets).filter((s) => !initialSecrets[s])
if (newSecrets.length > 0) {
throw new errors.BotpressCLIError('Secrets were added while the server was running. A restart is required.')
}
}
private _spawnWorker = async (env: Record<string, string>, port: number) => {
const outfile = this.projectPaths.abs.outFileCJS
const importPath = utils.path.toUnix(outfile)
const code = `require('${importPath}').default.start(${port})`
const worker = await Worker.spawn(
{
type: 'code',
code,
env,
},
this.logger
).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `Could not start dev worker on port ${port}`)
})
return worker
}
private _runBuild(watchEnabled = true) {
return new BuildCommand(this.api, this.prompt, this.logger, this.argv)
.setProjectContext(this.projectContext)
.run(watchEnabled ? this._buildContext : undefined)
}
private async _disposeBuildResources({ stopEsbuild = false } = {}) {
// Best-effort teardown: this runs from the `finally` of `run()`, so it must never throw —
// a failure here would mask the original error being propagated by the dev server.
try {
await Promise.all([this._buildContext.dispose(), this.projectContext.dispose()])
if (stopEsbuild) {
await utils.esbuild.stop()
}
} catch (thrown: unknown) {
const err = errors.BotpressCLIError.map(thrown)
this.logger.debug(`Failed to dispose build resources: ${err.message}`)
}
}
private async _deployDevIntegration(
api: apiUtils.ApiClient,
externalUrl: string,
integrationDef: sdk.IntegrationDefinition
): Promise<void> {
const devId = await this.projectCache.get('devId')
let integration: client.Integration | undefined = undefined
if (devId) {
const resp = await api.client.getIntegration({ id: devId }).catch(async (thrown) => {
const err = errors.BotpressCLIError.wrap(thrown, `Could not find existing dev integration with id "${devId}"`)
this.logger.warn(err.message)
this.logger.debug(errors.BotpressCLIError.fullStack(err))
return { integration: undefined }
})
if (resp.integration?.dev) {
integration = resp.integration
} else {
await this.projectCache.rm('devId')
}
}
const line = this.logger.line()
line.started(`Deploying dev integration ${chalk.bold(integrationDef.name)}...`)
const createIntegrationBody = {
...(await this.prepareCreateIntegrationBody(integrationDef)),
...(await this.prepareIntegrationDependencies(integrationDef, api)),
url: externalUrl,
}
if (integration) {
const updateIntegrationBody = apiUtils.prepareUpdateIntegrationBody(
{ ...createIntegrationBody, id: integration.id },
integration
)
const resp = await api.client.updateIntegration(updateIntegrationBody).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `Could not update dev integration "${integrationDef.name}"`)
})
integration = resp.integration
} else {
const resp = await api.client.createIntegration({ ...createIntegrationBody, dev: true }).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `Could not deploy dev integration "${integrationDef.name}"`)
})
integration = resp.integration
}
line.success(`Dev Integration deployed with id "${integration.id}" at "${externalUrl}"`)
line.commit()
await this.projectCache.set('devId', integration.id)
}
private async _deployDevBot(api: apiUtils.ApiClient, externalUrl: string, botDef: sdk.BotDefinition): Promise<void> {
const devId = await this.projectCache.get('devId')
let bot: client.Bot | undefined = undefined
if (devId) {
const resp = await api.client.getBot({ id: devId }).catch(async (thrown) => {
const err = errors.BotpressCLIError.wrap(thrown, `Could not find existing dev bot with id "${devId}"`)
this.logger.warn(err.message)
this.logger.debug(errors.BotpressCLIError.fullStack(err))
return { bot: undefined }
})
if (resp.bot?.dev) {
bot = resp.bot
} else {
await this.projectCache.rm('devId')
}
}
if (!bot) {
const createLine = this.logger.line()
createLine.started('Creating dev bot...')
const resp = await api.client
.createBot({
dev: true,
url: externalUrl,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Could not deploy dev bot')
})
bot = resp.bot
createLine.log('Dev Bot created')
createLine.commit()
await this.projectCache.set('devId', bot.id)
}
const updateBotBody = apiUtils.prepareUpdateBotBody(
{
...(await apiUtils.prepareCreateBotBody(botDef)),
...(await this.prepareBotDependencies(botDef, api)),
id: bot.id,
url: externalUrl,
},
bot
)
if (!(await this._didDefinitionChange(updateBotBody))) {
this.logger.log('Skipping deployment step. No changes found in bot.definition.ts')
return
}
const updateLine = this.logger.line()
updateLine.started('Deploying dev bot...')
const { bot: updatedBot } = await api.client.updateBot(updateBotBody).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Could not deploy dev bot')
})
this.validateIntegrationRegistration(updatedBot, (failedIntegrations) => {
throw new errors.BotpressCLIError(
`Some integrations failed to register:\n${Object.entries(failedIntegrations)
.map(([key, int]) => `${key}: ${int.statusReason}`)
.join('\n')}`
)
})
updateLine.success(`Dev Bot deployed with id "${updatedBot.id}" at "${externalUrl}"`)
updateLine.commit()
const tablesPublisher = new tables.TablesPublisher({ api, logger: this.logger, prompt: this.prompt })
await tablesPublisher.deployTables({ botId: updatedBot.id, botDefinition: botDef })
await this.displayIntegrationUrls({ api, bot: updatedBot })
}
private async _didDefinitionChange(body: apiUtils.UpdateBotRequestBody | apiUtils.UpdateIntegrationRequestBody) {
const didChange = !isEqual(body, this._cacheDevRequestBody)
this._cacheDevRequestBody = { ...body }
return didChange
}
private _forwardTunnelRequest = async (baseUrl: string, request: TunnelRequest): Promise<TunnelResponse> => {
const axiosConfig = {
method: request.method,
url: this._formatLocalUrl(baseUrl, request),
headers: request.headers,
data: request.body,
responseType: 'text',
validateStatus: () => true,
} satisfies AxiosRequestConfig
this.logger.debug(`Forwarding request to ${axiosConfig.url}`)
const response = await axios(axiosConfig)
this.logger.debug('Sending back response up the tunnel')
return {
requestId: request.id,
status: response.status,
headers: this._getHeaders(response.headers),
body: response.data,
}
}
private _formatLocalUrl = (baseUrl: string, req: TunnelRequest): string => {
if (req.query) {
return `${baseUrl}${req.path}?${req.query}`
}
return `${baseUrl}${req.path}`
}
private _getHeaders = (res: AxiosResponse['headers']): TunnelResponse['headers'] => {
const headers: TunnelResponse['headers'] = {}
for (const key in res) {
if (typeof res[key] === 'string' || typeof res[key] === 'number') {
headers[key] = String(res[key])
}
}
return headers
}
}
@@ -0,0 +1,101 @@
import * as sdk from '@botpress/sdk'
import chalk from 'chalk'
import fslib from 'fs'
import pathlib from 'path'
import * as codegen from '../code-generation'
import type commandDefinitions from '../command-definitions'
import * as errors from '../errors'
import * as utils from '../utils'
import { ProjectCommand } from './project-command'
export type GenerateCommandDefinition = typeof commandDefinitions.generate
export class GenerateCommand extends ProjectCommand<GenerateCommandDefinition> {
public async run(): Promise<void> {
const { projectType, resolveProjectDefinition } = this.readProjectDefinitionFromFS()
if (projectType === 'interface') {
this.logger.success('Interface projects have no code to generate since they have no implementation.')
return
}
if (projectType === 'integration') {
const projectDef = await resolveProjectDefinition()
return await this._generateIntegration(projectDef.definition)
}
if (projectType === 'bot') {
const projectDef = await resolveProjectDefinition()
return await this._generateBot(projectDef.definition)
}
if (projectType === 'plugin') {
const projectDef = await resolveProjectDefinition()
return await this._generatePlugin(projectDef.definition)
}
throw new errors.UnsupportedProjectType()
}
private async _generateIntegration(integrationDef: sdk.IntegrationDefinition): Promise<void> {
this._validateSecrets(integrationDef)
const line = this.logger.line()
const { name } = integrationDef
line.started(`Generating typings for integration ${chalk.bold(name)}...`)
const fromWorkDir = this.projectPaths.rel('workDir')
const generatedFiles = await codegen.generateIntegrationImplementation(integrationDef)
await this._writeGeneratedFilesToOutFolder(generatedFiles)
line.success(`Typings available at ${chalk.grey(fromWorkDir.outDir)}`)
}
private async _generateBot(botDefinition: sdk.BotDefinition): Promise<void> {
const line = this.logger.line()
line.started('Generating typings for bot...')
const fromWorkDir = this.projectPaths.rel('workDir')
const generatedFiles = await codegen.generateBotImplementation(botDefinition.dereferencePluginEntities())
await this._writeGeneratedFilesToOutFolder(generatedFiles)
line.success(`Typings available at ${chalk.grey(fromWorkDir.outDir)}`)
}
private async _generatePlugin(pluginDefinition: sdk.PluginDefinition): Promise<void> {
const line = this.logger.line()
const { name } = pluginDefinition
line.started(`Generating typings for plugin ${chalk.bold(name)}...`)
const fromWorkDir = this.projectPaths.rel('workDir')
const generatedFiles = await codegen.generatePluginImplementation(pluginDefinition.dereferenceEntities())
await this._writeGeneratedFilesToOutFolder(generatedFiles)
line.success(`Typings available at ${chalk.grey(fromWorkDir.outDir)}`)
}
private async _writeGeneratedFilesToOutFolder(files: codegen.File[]) {
for (const file of files) {
const filePath = utils.path.absoluteFrom(this.projectPaths.abs.outDir, file.path)
const dirPath = pathlib.dirname(filePath)
await fslib.promises.mkdir(dirPath, { recursive: true })
await fslib.promises.writeFile(filePath, file.content)
}
}
private _validateSecrets(integrationDef: sdk.IntegrationDefinition): void {
const { secrets } = integrationDef
if (!secrets) {
return
}
for (const secretName in secrets) {
if (!utils.casing.is.screamingSnakeCase(secretName)) {
throw new errors.BotpressCLIError(`Secret ${secretName} should be in SCREAMING_SNAKE_CASE`)
}
}
}
}
@@ -0,0 +1,221 @@
import { z } from '@botpress/sdk'
import type { YargsConfig } from '@bpinternal/yargs-extra'
import chalk from 'chalk'
import * as fs from 'fs'
import latestVersion from 'latest-version'
import _ from 'lodash'
import semver from 'semver'
import type { ApiClientFactory } from '../api/client'
import * as config from '../config'
import * as consts from '../consts'
import * as errors from '../errors'
import type { CommandArgv, CommandDefinition } from '../typings'
import * as utils from '../utils'
import { BaseCommand } from './base-command'
export type GlobalCommandDefinition = CommandDefinition<typeof config.schemas.global>
export type GlobalCache = { apiUrl: string; token: string; workspaceId: string; activeProfile: string }
export type ConfigurableGlobalPaths = {
botpressHomeDir: string
cliRootDir: utils.path.AbsolutePath
profilesPath: string
}
export type ConstantGlobalPaths = typeof consts.fromHomeDir & typeof consts.fromCliRootDir
export type AllGlobalPaths = ConfigurableGlobalPaths & ConstantGlobalPaths
const profileCredentialSchema = z.object({ apiUrl: z.string(), workspaceId: z.string(), token: z.string() })
export type ProfileCredentials = z.infer<typeof profileCredentialSchema>
class GlobalPaths extends utils.path.PathStore<keyof AllGlobalPaths> {
public constructor(argv: CommandArgv<GlobalCommandDefinition>) {
const absBotpressHome = utils.path.absoluteFrom(utils.path.cwd(), argv.botpressHome)
super({
cliRootDir: consts.cliRootDir,
botpressHomeDir: absBotpressHome,
profilesPath: utils.path.absoluteFrom(absBotpressHome, consts.profileFileName),
..._.mapValues(consts.fromHomeDir, (p) => utils.path.absoluteFrom(absBotpressHome, p)),
..._.mapValues(consts.fromCliRootDir, (p) => utils.path.absoluteFrom(consts.cliRootDir, p)),
})
}
}
export abstract class GlobalCommand<C extends GlobalCommandDefinition> extends BaseCommand<C> {
protected api: ApiClientFactory
protected prompt: utils.prompt.CLIPrompt
private _pkgJson: utils.pkgJson.PackageJson | undefined
public constructor(
api: ApiClientFactory,
prompt: utils.prompt.CLIPrompt,
...args: ConstructorParameters<typeof BaseCommand<C>>
) {
super(...args)
this.api = api
this.prompt = prompt
}
protected get globalPaths() {
return new GlobalPaths(this.argv)
}
protected get globalCache() {
return new utils.cache.FSKeyValueCache<GlobalCache>(this.globalPaths.abs.globalCacheFile)
}
protected override async bootstrap() {
const pkgJson = await this.readCLIPkgJson()
const versionText = chalk.bold(`v${pkgJson.version}`)
this.logger.log(`Botpress CLI ${versionText}`, { prefix: '🤖' })
await this._notifyUpdateCli()
const paths = this.globalPaths
if (paths.abs.botpressHomeDir !== consts.defaultBotpressHome) {
this.logger.log(`Using custom botpress home: ${paths.abs.botpressHomeDir}`, { prefix: '🏠' })
}
}
protected override teardown = async () => {
this.logger.cleanup()
}
protected async getAuthenticatedClient(credentials: Partial<YargsConfig<typeof config.schemas.credentials>>) {
try {
const cache = this.globalCache
let token: string | undefined
let workspaceId: string | undefined
let apiUrl: string | undefined
if (this.argv.profile) {
if (credentials.token || credentials.workspaceId || credentials.apiUrl) {
this.logger.warn(
'You are currently using credential command line arguments or environment variables as well as a profile. Your profile has overwritten the variables'
)
}
;({ token, workspaceId, apiUrl } = await this.readProfileFromFS(this.argv.profile))
this.logger.log(`Using profile "${this.argv.profile}"`, { prefix: '👤' })
} else {
token = credentials.token ?? (await cache.get('token'))
workspaceId = credentials.workspaceId ?? (await cache.get('workspaceId'))
apiUrl = credentials.apiUrl ?? (await cache.get('apiUrl'))
}
if (!(token && workspaceId && apiUrl)) {
return null
}
if (apiUrl !== consts.defaultBotpressApiUrl) {
this.logger.log(`Using custom url ${apiUrl}`, { prefix: '🔗' })
}
return this.api.newClient({ apiUrl, token, workspaceId }, this.logger)
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, 'failed to create authenticated client')
}
}
protected async readProfileFromFS(profile: string): Promise<ProfileCredentials> {
const parsedProfiles = await this.readProfilesFromFS()
const profileData = parsedProfiles[profile]
if (!profileData) {
throw new errors.BotpressCLIError(
`Profile "${profile}" not found in "${this.globalPaths.abs.profilesPath}". Found profiles '${Object.keys(parsedProfiles).join("', '")}'.`
)
}
return profileData
}
protected async readProfilesFromFS(): Promise<Record<string, ProfileCredentials>> {
if (!fs.existsSync(this.globalPaths.abs.profilesPath)) {
throw new errors.BotpressCLIError(`Profile file not found at "${this.globalPaths.abs.profilesPath}"`)
}
const fileContent = await fs.promises.readFile(this.globalPaths.abs.profilesPath, 'utf-8')
const jsonParseResult = utils.json.safeParseJson(fileContent)
if (!jsonParseResult.success) {
throw new errors.BotpressCLIError(`Error parsing profiles file: ${jsonParseResult.error.message}`)
}
const zodParseResult = z.record(profileCredentialSchema).safeParse(jsonParseResult.data)
if (!zodParseResult.success) {
throw errors.BotpressCLIError.wrap(zodParseResult.error, 'Error parsing profiles: ')
}
return zodParseResult.data
}
protected async writeProfileToFS(profileName: string, profile: ProfileCredentials): Promise<void> {
let profiles: Record<string, ProfileCredentials>
if (fs.existsSync(this.globalPaths.abs.profilesPath)) {
profiles = await this.readProfilesFromFS()
} else {
profiles = {}
}
profiles[profileName] = profile
await fs.promises.writeFile(
this.globalPaths.abs.profilesPath,
JSON.stringify({ [consts.defaultProfileName]: profiles.defaultProfileName, ...profiles }, null, 2),
'utf-8'
)
}
protected async ensureLoginAndCreateClient(credentials: YargsConfig<typeof config.schemas.credentials>) {
const client = await this.getAuthenticatedClient(credentials)
if (client === null) {
throw new errors.NotLoggedInError()
}
return client
}
private readonly _notifyUpdateCli = async (): Promise<void> => {
try {
this.logger.debug('Checking if cli is up to date')
const pkgJson = await this.readCLIPkgJson()
if (!pkgJson.version) {
throw new errors.BotpressCLIError('Could not find version in package.json')
}
const latest = await latestVersion(pkgJson.name)
const isOutdated = semver.lt(pkgJson.version, latest)
if (isOutdated) {
this.logger.box(
[
`${chalk.bold('Update available')} ${chalk.dim(pkgJson.version)}${chalk.green(latest)}`,
'',
'To update, run:',
` for npm ${chalk.cyan(`npm i -g ${pkgJson.name}`)}`,
` for yarn ${chalk.cyan(`yarn global add ${pkgJson.name}`)}`,
` for pnpm ${chalk.cyan(`pnpm i -g ${pkgJson.name}`)}`,
].join('\n')
)
}
} catch (thrown) {
const err = errors.BotpressCLIError.map(thrown)
this.logger.debug(`Failed to check if cli is up to date: ${err.message}`)
}
}
protected async readCLIPkgJson(): Promise<utils.pkgJson.PackageJson> {
if (this._pkgJson) {
return this._pkgJson
}
const { cliRootDir } = this.globalPaths.abs
const pkgJson = await utils.pkgJson.readPackageJson(cliRootDir).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `Failed to read CLI package.json file at "${cliRootDir}"`)
})
if (!pkgJson) {
throw new errors.BotpressCLIError(`Could not find package.json at "${cliRootDir}"`)
}
this._pkgJson = pkgJson
return pkgJson
}
}
@@ -0,0 +1,93 @@
import { ApiClient } from '../api/client'
import type commandDefinitions from '../command-definitions'
import type { ImplementationTree } from '../command-tree'
import { Logger } from '../logger'
import type { CommandArgv } from '../typings'
import * as utils from '../utils'
import { AddCommand } from './add-command'
import type { BaseCommand } from './base-command'
import * as bots from './bot-commands'
import { BuildCommand } from './build-command'
import { BundleCommand } from './bundle-command'
import { ChatCommand } from './chat-command'
import { DeployCommand } from './deploy-command'
import { DevCommand } from './dev-command'
import { GenerateCommand } from './gen-command'
import type { GlobalCommand, GlobalCommandDefinition } from './global-command'
import { InitCommand } from './init-command'
import * as integrations from './integration-commands'
import * as interfaces from './interface-commands'
import { LintCommand } from './lint-command'
import { LoginCommand } from './login-command'
import { LogoutCommand } from './logout-command'
import * as plugins from './plugin-commands'
import * as profiles from './profile-commands'
import { ReadCommand } from './read-command'
import { RemoveCommand } from './remove-command'
import { ServeCommand } from './serve-command'
type GlobalCtor<C extends GlobalCommandDefinition> = new (
...args: ConstructorParameters<typeof GlobalCommand<C>>
) => BaseCommand<C>
const getHandler =
<C extends GlobalCommandDefinition>(cls: GlobalCtor<C>) =>
async (argv: CommandArgv<C>) => {
const logger = new Logger(argv)
const prompt = new utils.prompt.CLIPrompt(argv, logger)
return new cls(ApiClient, prompt, logger, argv).handler()
}
export default {
login: getHandler(LoginCommand),
logout: getHandler(LogoutCommand),
bots: {
subcommands: {
create: getHandler(bots.CreateBotCommand),
get: getHandler(bots.GetBotCommand),
delete: getHandler(bots.DeleteBotCommand),
list: getHandler(bots.ListBotsCommand),
},
},
integrations: {
subcommands: {
get: getHandler(integrations.GetIntegrationCommand),
list: getHandler(integrations.ListIntegrationsCommand),
delete: getHandler(integrations.DeleteIntegrationCommand),
},
},
interfaces: {
subcommands: {
get: getHandler(interfaces.GetInterfaceCommand),
list: getHandler(interfaces.ListInterfacesCommand),
delete: getHandler(interfaces.DeleteInterfaceCommand),
},
},
plugins: {
subcommands: {
get: getHandler(plugins.GetPluginCommand),
list: getHandler(plugins.ListPluginsCommand),
delete: getHandler(plugins.DeletePluginCommand),
},
},
init: getHandler(InitCommand),
generate: getHandler(GenerateCommand),
bundle: getHandler(BundleCommand),
build: getHandler(BuildCommand),
read: getHandler(ReadCommand),
serve: getHandler(ServeCommand),
deploy: getHandler(DeployCommand),
add: getHandler(AddCommand),
remove: getHandler(RemoveCommand),
dev: getHandler(DevCommand),
lint: getHandler(LintCommand),
chat: getHandler(ChatCommand),
profiles: {
subcommands: {
list: getHandler(profiles.ListProfilesCommand),
active: getHandler(profiles.ActiveProfileCommand),
use: getHandler(profiles.UseProfileCommand),
get: getHandler(profiles.GetProfileCommand),
},
},
} satisfies ImplementationTree<typeof commandDefinitions>
@@ -0,0 +1,377 @@
import type * as client from '@botpress/client'
import chalk from 'chalk'
import * as fs from 'fs'
import * as pathlib from 'path'
import { ApiClient } from '../api'
import type commandDefinitions from '../command-definitions'
import * as errors from '../errors'
import { Logger } from '../logger'
import { ProjectTemplates } from '../project-templates'
import * as utils from '../utils'
import { GlobalCommand } from './global-command'
const projectTypes = ['bot', 'integration', 'plugin'] as const
type ProjectType = (typeof projectTypes)[number]
type CopyProps = { srcDir: string; destDir: string; name: string; pkgJson: Record<string, unknown> }
export type InitCommandDefinition = typeof commandDefinitions.init
export class InitCommand extends GlobalCommand<InitCommandDefinition> {
public async run(): Promise<void> {
const projectType = await this._promptProjectType()
const workDir = utils.path.absoluteFrom(utils.path.cwd(), this.argv.workDir)
try {
if (projectType === 'bot') {
await this._initBot({ workDir })
return
}
if (projectType === 'integration') {
const workspaceHandle = await this._promptWorkspaceHandle()
await this._initIntegration({ workDir, workspaceHandle })
return
}
if (projectType === 'plugin') {
const workspaceHandle = await this._promptWorkspaceHandle()
await this._initPlugin({ workDir, workspaceHandle })
return
}
} catch (error) {
if (error instanceof errors.AbortedOperationError) {
this.logger.log(error.message)
return
}
throw error
}
type _assertion = utils.types.AssertNever<typeof projectType>
throw new errors.BotpressCLIError(`Unknown project type: ${projectType}`)
}
private async _promptWorkspaceHandle() {
const client = (await this.getAuthenticatedClient({})) ?? undefined
const nameParts = this.argv.name?.split('/', 2) ?? []
const workspaceHandle = nameParts.length > 1 ? nameParts[0] : undefined
const resolver = await WorkspaceResolver.from({
client,
workspaceHandle,
prompt: this.prompt,
logger: this.logger,
})
return await resolver.getWorkspaceHandle()
}
private async _promptProjectType() {
if (this.argv.type) {
return this.argv.type
}
const promptedType = await this.prompt.select('What type of project do you wish to initialize?', {
choices: projectTypes.map((t) => ({ title: t, value: t })),
})
if (!promptedType) {
throw new errors.ParamRequiredError('Project Type')
}
return promptedType
}
private _initPlugin = async (args: { workDir: string; workspaceHandle: string }) => {
const { workDir, workspaceHandle } = args
const template = await this._getOrPromptForTemplate('plugin')
const name = await this._getName('plugin', template.defaultProjectName)
const { fullName, shortName } = this._getFullNameAndShortName({ workspaceHandle, name })
await this._copy({
srcDir: template.absolutePath,
destDir: workDir,
name: shortName,
pkgJson: {
pluginName: fullName,
},
})
this.logger.success(`Plugin project initialized in ${chalk.bold(pathlib.join(workDir, shortName))}`)
}
private async _getOrPromptForTemplate(type: ProjectType): Promise<ProjectTemplates.Template> {
const availableTemplates = ProjectTemplates.templates[type]
if (this.argv.template) {
const template = availableTemplates.find((t) => t.identifier === this.argv.template)
if (!template) {
throw new errors.BotpressCLIError(`No ${type} template found for identifier "${this.argv.template}"`)
}
return template
}
if (availableTemplates.length === 1) {
this.logger.log(`Using default template: ${chalk.bold(availableTemplates[0].fullName)}`)
return availableTemplates[0]
}
return await this._promptForTemplate(availableTemplates)
}
private async _promptForTemplate(
availableTemplates: ProjectTemplates.TemplateArray
): Promise<ProjectTemplates.Template> {
const templateIndex = await this.prompt.select<number>('Which template do you want to use?', {
choices: availableTemplates.map((template, index) => ({
title: template.fullName,
value: index,
})),
default: 0,
})
if (templateIndex === undefined || templateIndex < 0 || templateIndex >= availableTemplates.length) {
this.logger.log(`Using default template: ${chalk.bold(availableTemplates[0].fullName)}`)
return availableTemplates[0]
}
return availableTemplates[templateIndex]!
}
private _getFullNameAndShortName(args: { workspaceHandle: string; name: string }) {
const [workspaceOrName, projectName] = args.name.split('/', 2)
const shortName = projectName ?? workspaceOrName!
const fullName = `${args.workspaceHandle}/${shortName}`
return { shortName, fullName }
}
private _initBot = async (args: { workDir: string }) => {
const { workDir } = args
const template = await this._getOrPromptForTemplate('bot')
const name = await this._getName('bot', template.defaultProjectName)
await this._copy({ srcDir: template.absolutePath, destDir: workDir, name, pkgJson: {} })
this.logger.success(`Bot project initialized in ${chalk.bold(pathlib.join(workDir, name))}`)
}
private _initIntegration = async (args: { workDir: string; workspaceHandle: string }) => {
const { workDir, workspaceHandle } = args
const template = await this._getOrPromptForTemplate('integration')
const name = await this._getName('integration', template.defaultProjectName)
const { fullName, shortName } = this._getFullNameAndShortName({ workspaceHandle, name })
await this._copy({
srcDir: template.absolutePath,
destDir: workDir,
name: shortName,
pkgJson: {
integrationName: fullName,
},
})
this.logger.success(`Integration project initialized in ${chalk.bold(pathlib.join(workDir, shortName))}`)
return
}
private _getName = async (projectType: ProjectType, defaultName: string): Promise<string> => {
if (this.argv.name) {
return this.argv.name
}
const promptMessage = `What is the name of your ${projectType}?`
const promptedName = await this.prompt.text(promptMessage, { initial: defaultName })
if (!promptedName) {
throw new errors.ParamRequiredError('Project Name')
}
return promptedName
}
private _copy = async (props: CopyProps) => {
const { srcDir, destDir, name, pkgJson } = props
const dirName = utils.casing.to.kebabCase(name)
const destination = pathlib.join(destDir, dirName)
const destinationCanBeUsed = await this._checkIfDestinationCanBeUsed(destination)
if (!destinationCanBeUsed) {
throw new errors.AbortedOperationError()
}
await fs.promises.rm(destination, { recursive: true, force: true })
await fs.promises.cp(srcDir, destination, { recursive: true })
const json = await utils.pkgJson.readPackageJson(destination).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Failed to read package.json file')
})
const pkgJsonName = utils.casing.to.snakeCase(name)
const updatedJson = { name: pkgJsonName, ...json, ...pkgJson }
await utils.pkgJson.writePackageJson(destination, updatedJson).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Failed to write package.json file')
})
}
private _checkIfDestinationCanBeUsed = async (destination: string) => {
if (fs.existsSync(destination)) {
const override = await this.prompt.confirm(
`Directory ${chalk.bold(destination)} already exists. Do you want to overwrite it?`
)
if (!override) {
return false
}
}
return true
}
}
class WorkspaceResolver {
private _workspaces: client.Workspace[] = []
private _currentWorkspace?: client.Workspace
private constructor(
private readonly _client: ApiClient | undefined,
private readonly _workspaceHandle: string | undefined,
private readonly _prompt: utils.prompt.CLIPrompt,
private readonly _logger: Logger
) {}
public static async from({
client,
workspaceHandle,
prompt,
logger,
}: {
client?: ApiClient
workspaceHandle?: string
prompt: utils.prompt.CLIPrompt
logger: Logger
}): Promise<WorkspaceResolver> {
const resolver = new WorkspaceResolver(client, workspaceHandle, prompt, logger)
await resolver._fetchWorkspaces()
return resolver
}
public async getWorkspaceHandle(): Promise<string> {
if (this._hasNoWorkspaces()) {
return this._promptForArbitraryWorkspaceHandle()
}
const workspace = await this._promptUserToSelectWorkspace()
return workspace.handle || (await this._assignHandleToWorkspace(workspace))
}
private async _fetchWorkspaces(): Promise<void> {
if (!this._isAuthenticated()) {
return
}
const workspaces = await this._getClient()
.client.list.workspaces({})
.collect({})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Unable to list your workspaces')
})
const currentWorkspace = workspaces.find((ws) => ws.id === this._getClient().workspaceId)
this._workspaces = workspaces
this._currentWorkspace = currentWorkspace
}
private _isAuthenticated(): boolean {
return !!this._client
}
private _hasNoWorkspaces(): boolean {
return !this._isAuthenticated() || this._workspaces.length === 0 || !this._currentWorkspace
}
private async _promptForArbitraryWorkspaceHandle(): Promise<string> {
if (this._workspaceHandle) {
return this._workspaceHandle
}
const handle = await this._prompt.text('Enter your workspace handle')
if (!handle) {
throw new errors.ParamRequiredError('Workspace handle')
}
return handle
}
private async _promptUserToSelectWorkspace(): Promise<client.Workspace> {
const workspaceChoices = this._workspaces.map((ws) => ({
title: ws.name,
value: ws.id,
}))
const initialChoice = {
title: this._currentWorkspace!.name,
value: this._currentWorkspace!.id,
}
const workspaceId = await this._prompt.select('Which workspace do you want to use?', {
initial: initialChoice,
choices: workspaceChoices,
})
if (!workspaceId) {
throw new errors.ParamRequiredError('Workspace')
}
return this._workspaces.find((ws) => ws.id === workspaceId)!
}
private async _assignHandleToWorkspace(workspace: client.Workspace): Promise<string> {
this._logger.warn("It seems you don't have a workspace handle yet.")
let claimedHandle: string | undefined
do {
const desiredHandle = await this._promptForDesiredHandle()
const isAvailable = await this._checkHandleAvailability(desiredHandle)
if (isAvailable) {
claimedHandle = desiredHandle
await this._updateWorkspaceWithHandle(workspace.id, claimedHandle)
}
} while (!claimedHandle)
this._logger.success(`Handle "${claimedHandle}" is yours!`)
return claimedHandle
}
private async _promptForDesiredHandle(): Promise<string> {
const desiredHandle = await this._prompt.text('Please enter a workspace handle')
if (!desiredHandle) {
throw new errors.BotpressCLIError('Workspace handle is required')
}
return desiredHandle
}
private async _checkHandleAvailability(handle: string): Promise<boolean> {
const { available, suggestions } = await this._getClient().client.checkHandleAvailability({ handle })
if (!available) {
this._logger.warn(`Handle "${handle}" is not available. Suggestions: ${suggestions.join(', ')}`)
return false
}
return true
}
private async _updateWorkspaceWithHandle(workspaceId: string, handle: string): Promise<void> {
try {
await this._getClient().switchWorkspace(workspaceId).updateWorkspace({ handle })
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, `Could not claim handle "${handle}"`)
}
}
private _getClient(): ApiClient {
if (!this._client) {
throw new errors.BotpressCLIError('Could not authenticate')
}
return this._client
}
}
@@ -0,0 +1,157 @@
import chalk from 'chalk'
import _ from 'lodash'
import { ApiClient, PublicOrPrivateIntegration, IntegrationSummary } from '../api/client'
import type commandDefinitions from '../command-definitions'
import * as errors from '../errors'
import { NamePackageRef, parsePackageRef } from '../package-ref'
import { GlobalCommand } from './global-command'
export type GetIntegrationCommandDefinition = typeof commandDefinitions.integrations.subcommands.get
export class GetIntegrationCommand extends GlobalCommand<GetIntegrationCommandDefinition> {
public async run(): Promise<void> {
const api = await this.ensureLoginAndCreateClient(this.argv)
const parsedRef = parsePackageRef(this.argv.integrationRef)
if (!parsedRef) {
throw new errors.InvalidPackageReferenceError(this.argv.integrationRef)
}
if (parsedRef.type === 'path') {
throw new errors.BotpressCLIError('Cannot get local integration')
}
try {
const integration = await api.findPublicOrPrivateIntegration(parsedRef)
if (integration) {
this.logger.success(`Integration ${chalk.bold(this.argv.integrationRef)}:`)
this.logger.json(integration)
return
}
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, `Could not get integration ${this.argv.integrationRef}`)
}
throw new errors.BotpressCLIError(`Integration ${this.argv.integrationRef} not found`)
}
}
export type ListIntegrationsCommandDefinition = typeof commandDefinitions.integrations.subcommands.list
export class ListIntegrationsCommand extends GlobalCommand<ListIntegrationsCommandDefinition> {
public async run(): Promise<void> {
const api = await this.ensureLoginAndCreateClient(this.argv)
const { dev, public: isPublic, owned } = this.argv
if (dev && isPublic) {
throw new errors.BotpressCLIError(
'Cannot use --dev and --public flags together as dev integrations are always private'
)
}
if (dev && owned) {
throw new errors.BotpressCLIError(
'Cannot use --dev and --owned flags together as dev integrations are always owned by the current workspace'
)
}
try {
const integrations = await this._listAllIntegrations(api)
this.logger.success('Integrations:')
this.logger.json(integrations)
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, 'Could not list integrations')
}
}
private _listAllIntegrations = async (api: ApiClient): Promise<IntegrationSummary[]> => {
if (this.argv.dev) {
return this._listDevIntegrations(api)
}
if (this.argv.public && this.argv.owned) {
const [owned, publicIntegrations] = await Promise.all([
this._listOwnedIntegrations(api),
this._listPublicIntegrations(api),
])
return _.intersectionBy(owned, publicIntegrations, (i) => i.id).slice(0, this.argv.limit)
}
if (this.argv.owned) {
return this._listOwnedIntegrations(api)
}
if (this.argv.public) {
return this._listPublicIntegrations(api)
}
const [owned, publicIntegrations] = await Promise.all([
this._listOwnedIntegrations(api),
this._listPublicIntegrations(api),
])
return _.uniqBy([...owned, ...publicIntegrations], (i) => i.id).slice(0, this.argv.limit)
}
private _listDevIntegrations = async (api: ApiClient): Promise<IntegrationSummary[]> => {
const { name, versionNumber: version } = this.argv
return api.client.list.integrations({ dev: true, name, version }).collect({ limit: this.argv.limit })
}
private _listOwnedIntegrations = async (api: ApiClient): Promise<IntegrationSummary[]> => {
const { name, versionNumber: version } = this.argv
return api.client.list.integrations({ name, version }).collect({ limit: this.argv.limit })
}
private _listPublicIntegrations = async (api: ApiClient): Promise<IntegrationSummary[]> => {
const { name, versionNumber: version } = this.argv
return api.client.list.publicIntegrations({ name, version }).collect({ limit: this.argv.limit })
}
}
export type DeleteIntegrationCommandDefinition = typeof commandDefinitions.integrations.subcommands.delete
export class DeleteIntegrationCommand extends GlobalCommand<DeleteIntegrationCommandDefinition> {
public async run(): Promise<void> {
const api = await this.ensureLoginAndCreateClient(this.argv)
const parsedRef = parsePackageRef(this.argv.integrationRef)
if (!parsedRef) {
throw new errors.InvalidPackageReferenceError(this.argv.integrationRef)
}
if (parsedRef.type === 'path') {
throw new errors.BotpressCLIError('Cannot delete local integration')
}
let integrationId: string | undefined
if (parsedRef.type === 'id') {
integrationId = parsedRef.id
} else {
const integration = await this._findIntegration(api, parsedRef)
integrationId = integration.id
}
try {
await api.client.deleteIntegration({ id: integrationId })
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, `Could not delete integration ${this.argv.integrationRef}`)
}
this.logger.success(`Integration ${chalk.bold(this.argv.integrationRef)} deleted`)
return
}
private _findIntegration = async (api: ApiClient, parsedRef: NamePackageRef) => {
let integration: PublicOrPrivateIntegration | undefined
try {
integration = await api.findPrivateIntegration(parsedRef)
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, `Could not get integration ${this.argv.integrationRef}`)
}
if (!integration) {
const publicIntegration = await api.findPublicIntegration(parsedRef)
if (publicIntegration) {
throw new errors.BotpressCLIError(`Integration ${this.argv.integrationRef} does not belong to your workspace`)
}
throw new errors.BotpressCLIError(`Integration ${this.argv.integrationRef} not found`)
}
return integration
}
}
@@ -0,0 +1,91 @@
import type * as client from '@botpress/client'
import chalk from 'chalk'
import _ from 'lodash'
import type commandDefinitions from '../command-definitions'
import * as errors from '../errors'
import { parsePackageRef } from '../package-ref'
import { GlobalCommand } from './global-command'
export type GetInterfaceCommandDefinition = typeof commandDefinitions.interfaces.subcommands.get
export class GetInterfaceCommand extends GlobalCommand<GetInterfaceCommandDefinition> {
public async run(): Promise<void> {
const api = await this.ensureLoginAndCreateClient(this.argv)
const parsedRef = parsePackageRef(this.argv.interfaceRef)
if (!parsedRef) {
throw new errors.InvalidPackageReferenceError(this.argv.interfaceRef)
}
if (parsedRef.type === 'path') {
throw new errors.BotpressCLIError('Cannot get local interface')
}
try {
const intrface = await api.findPublicOrPrivateInterface(parsedRef)
if (intrface) {
this.logger.success(`Interface ${chalk.bold(this.argv.interfaceRef)}:`)
this.logger.json(intrface)
return
}
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, `Could not get interface ${this.argv.interfaceRef}`)
}
throw new errors.BotpressCLIError(`Interface ${this.argv.interfaceRef} not found`)
}
}
export type ListInterfacesCommandDefinition = typeof commandDefinitions.interfaces.subcommands.list
export class ListInterfacesCommand extends GlobalCommand<ListInterfacesCommandDefinition> {
public async run(): Promise<void> {
const api = await this.ensureLoginAndCreateClient(this.argv)
const privateLister = (req: { nextToken?: string }) => api.client.listInterfaces({ nextToken: req.nextToken })
const publicLister = (req: { nextToken?: string }) => api.client.listPublicInterfaces({ nextToken: req.nextToken })
try {
const [privateInterfaces, publicInterfaces] = await Promise.all([
api.listAllPages(privateLister, (r) => r.interfaces),
api.listAllPages(publicLister, (r) => r.interfaces),
])
const interfaces = _.uniqBy([...privateInterfaces, ...publicInterfaces], (i) => i.id)
this.logger.success('Interfaces:')
this.logger.json(interfaces)
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, 'Could not list interfaces')
}
}
}
export type DeleteInterfaceCommandDefinition = typeof commandDefinitions.interfaces.subcommands.delete
export class DeleteInterfaceCommand extends GlobalCommand<DeleteInterfaceCommandDefinition> {
public async run(): Promise<void> {
const api = await this.ensureLoginAndCreateClient(this.argv)
const parsedRef = parsePackageRef(this.argv.interfaceRef)
if (!parsedRef) {
throw new errors.InvalidPackageReferenceError(this.argv.interfaceRef)
}
if (parsedRef.type === 'path') {
throw new errors.BotpressCLIError('Cannot delete local interface')
}
let intrface: client.Interface | undefined
try {
intrface = await api.findPublicOrPrivateInterface(parsedRef)
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, `Could not get interface ${this.argv.interfaceRef}`)
}
if (!intrface) {
throw new errors.BotpressCLIError(`Interface ${this.argv.interfaceRef} not found`)
}
try {
await api.client.deleteInterface({ id: intrface.id })
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, `Could not delete interface ${this.argv.interfaceRef}`)
}
this.logger.success(`Interface ${chalk.bold(this.argv.interfaceRef)} deleted`)
return
}
}
@@ -0,0 +1,176 @@
import {
type IntegrationDefinition,
type BotDefinition,
type InterfaceDefinition,
ActionDefinition,
z,
EventDefinition,
} from '@botpress/sdk'
import * as apiUtils from '../api'
import type commandDefinitions from '../command-definitions'
import * as errors from '../errors'
import { BaseLinter } from '../linter/base-linter'
import { BotLinter } from '../linter/bot-linter'
import { IntegrationLinter } from '../linter/integration-linter'
import { InterfaceLinter } from '../linter/interface-linter'
import { ProjectCommand } from './project-command'
const _getIssuesDetectedMessage = (linter: BaseLinter<unknown>, nonEmptyPrefix: string = '') => {
const message = linter
.getIssuesCountBySeverity()
.map(({ name, count }) => `${count} ${name}(s)`)
.join(', ')
return message.length > 0 ? `${nonEmptyPrefix}${message}` : ''
}
export type LintCommandDefinition = typeof commandDefinitions.lint
export class LintCommand extends ProjectCommand<LintCommandDefinition> {
public async run(): Promise<void> {
const { projectType, resolveProjectDefinition } = this.readProjectDefinitionFromFS()
const projectDef = await resolveProjectDefinition()
if (projectDef.bpLintDisabled) {
this.logger.warn(
'Linting is disabled for this project because of a bplint directive. To enable linting, remove the "bplint-disable" directive from the project definition file'
)
return
}
switch (projectType) {
case 'integration': {
const projectDef = await resolveProjectDefinition()
return this._runLintForIntegration(projectDef.definition)
}
case 'bot': {
const projectDef = await resolveProjectDefinition()
return this._runLintForBot(projectDef.definition)
}
case 'interface': {
const projectDef = await resolveProjectDefinition()
return this._runLintForInterface(projectDef.definition)
}
default:
throw new errors.UnsupportedProjectType()
}
}
private async _runLintForInterface(definition: InterfaceDefinition): Promise<void> {
for (const [actionName, actionDef] of Object.entries(definition.actions)) {
definition.actions[actionName] = this._dereferenceActionDefinition(actionDef)
}
for (const [eventName, eventDef] of Object.entries(definition.events)) {
definition.events[eventName] = this._dereferenceEventDefinition(eventDef)
}
const parsedInterfaceDefinition = await apiUtils.prepareCreateInterfaceBody(definition)
const linter = new InterfaceLinter(parsedInterfaceDefinition, this.logger)
await linter.lint()
linter.logResults(this.logger)
const issueCountsSuffix = _getIssuesDetectedMessage(linter, ' - ')
if (linter.hasErrors()) {
throw new errors.BotpressCLIError(`Interface definition contains linting errors${issueCountsSuffix}`)
}
this.logger.success(`Interface definition is valid${issueCountsSuffix}`)
}
private async _runLintForBot(definition: BotDefinition): Promise<void> {
const strippedDefinition = this._stripAutoGeneratedContentFromBot(definition)
const parsedBotDefinition = await apiUtils.prepareCreateBotBody(strippedDefinition)
const linter = new BotLinter(parsedBotDefinition, this.logger)
await linter.lint()
linter.logResults(this.logger)
const issueCountsSuffix = _getIssuesDetectedMessage(linter, ' - ')
if (linter.hasErrors()) {
throw new errors.BotpressCLIError(`Bot definition contains linting errors${issueCountsSuffix}`)
}
this.logger.success(`Bot definition is valid${issueCountsSuffix}`)
}
private _stripAutoGeneratedContentFromBot(definition: BotDefinition) {
return {
...definition,
integrations: {},
} as BotDefinition
}
private async _runLintForIntegration(definition: IntegrationDefinition): Promise<void> {
const strippedDefinition = this._stripAutoGeneratedContentFromIntegration(definition)
const parsedIntegrationDefinition = await this.prepareCreateIntegrationBody(strippedDefinition)
const linter = new IntegrationLinter(
{ ...parsedIntegrationDefinition, secrets: strippedDefinition.secrets },
this.logger
)
await linter.lint()
linter.logResults(this.logger)
const issueCountsSuffix = _getIssuesDetectedMessage(linter, ' - ')
if (linter.hasErrors()) {
throw new errors.BotpressCLIError(`Integration definition contains linting errors${issueCountsSuffix}`)
}
this.logger.success(`Integration definition is valid${issueCountsSuffix}`)
}
private _stripAutoGeneratedContentFromIntegration(definition: IntegrationDefinition) {
const { actionNames, eventNames } = this._getAutoGeneratedContentOfIntegration(definition)
return {
...definition,
actions: Object.fromEntries(Object.entries(definition.actions ?? {}).filter(([key]) => !actionNames.has(key))),
events: Object.fromEntries(Object.entries(definition.events ?? {}).filter(([key]) => !eventNames.has(key))),
} as IntegrationDefinition
}
private _getAutoGeneratedContentOfIntegration(definition: IntegrationDefinition) {
const actionNames = new Set<string>()
const eventNames = new Set<string>()
const interfacesStatements = definition.interfaces ?? {}
for (const iface of Object.values(interfacesStatements)) {
for (const actionDefinition of Object.values(iface.actions)) {
actionNames.add(actionDefinition.name)
}
for (const eventDefinition of Object.values(iface.events)) {
eventNames.add(eventDefinition.name)
}
}
return { actionNames, eventNames } as const
}
private _dereferenceActionDefinition = (actionDef: ActionDefinition): ActionDefinition => {
const inputRefs = actionDef.input.schema.getReferences()
const outputRefs = actionDef.output.schema.getReferences()
const inputRefSchemas = Object.fromEntries(inputRefs.map((ref) => [ref, this._replaceRef(ref)]))
const outputRefSchemas = Object.fromEntries(outputRefs.map((ref) => [ref, this._replaceRef(ref)]))
actionDef.input.schema = actionDef.input.schema.dereference(inputRefSchemas) as z.ZodObject
actionDef.output.schema = actionDef.output.schema.dereference(outputRefSchemas) as z.ZodObject
return actionDef
}
private _dereferenceEventDefinition = (eventDef: EventDefinition): EventDefinition => {
const refs = eventDef.schema.getReferences()
const refSchemas = Object.fromEntries(refs.map((ref) => [ref, this._replaceRef(ref)]))
eventDef.schema = eventDef.schema.dereference(refSchemas) as z.ZodObject
return eventDef
}
private _replaceRef = (refUri: string): z.ZodObject => z.object({}).title(refUri).describe(refUri)
}
@@ -0,0 +1,89 @@
import * as client from '@botpress/client'
import * as fs from 'fs'
import * as paging from '../api/paging'
import type commandDefinitions from '../command-definitions'
import * as consts from '../consts'
import * as errors from '../errors'
import { GlobalCommand } from './global-command'
export type LoginCommandDefinition = typeof commandDefinitions.login
export class LoginCommand extends GlobalCommand<LoginCommandDefinition> {
public async run(): Promise<void> {
let profileName = consts.defaultProfileName
if (this.argv.profile) {
let profileExists: boolean = false
if (fs.existsSync(this.globalPaths.abs.profilesPath)) {
const profiles = await this.readProfilesFromFS()
profileExists = profiles[this.argv.profile] !== undefined
}
if (profileExists) {
const overwrite = await this.prompt.confirm(
`This command will overwrite the existing profile '${this.argv.profile}'. Do you want to continue?`
)
if (!overwrite) throw new errors.AbortedOperationError()
} else {
this.logger.log(`This command will create new profile '${this.argv.profile}'`, { prefix: '︎' })
}
profileName = this.argv.profile
}
const promptedToken = await this.globalCache.sync('token', this.argv.token, async (previousToken) => {
const prompted = await this.prompt.text('Enter your Personal Access Token', {
initial: previousToken,
})
if (!prompted) {
throw new errors.ParamRequiredError('Personal Access Token')
}
return prompted
})
if (this.argv.apiUrl !== consts.defaultBotpressApiUrl) {
this.logger.log(`Using custom api url ${this.argv.apiUrl} to try fetching workspaces`, { prefix: '🔗' })
}
const promptedWorkspaceId = await this.globalCache.sync('workspaceId', this.argv.workspaceId, async (defaultId) => {
const tmpClient = new client.Client({ apiUrl: this.argv.apiUrl, token: promptedToken }) // no workspaceId yet
const userWorkspaces = await paging
.listAllPages(tmpClient.listWorkspaces, (r) => r.workspaces)
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Could not list workspaces')
})
if (userWorkspaces.length === 0) {
throw new errors.NoWorkspacesFoundError()
}
const initial = userWorkspaces.find((ws) => ws.id === defaultId)
const prompted = await this.prompt.select('Which workspace do you want to login to?', {
initial: initial && { title: initial.name, value: initial.id },
choices: userWorkspaces.map((ws) => ({ title: ws.name, value: ws.id })),
})
if (!prompted) {
throw new errors.ParamRequiredError('Workspace Id')
}
return prompted
})
await this.globalCache.set('apiUrl', this.argv.apiUrl)
const api = this.api.newClient(
{ apiUrl: this.argv.apiUrl, token: promptedToken, workspaceId: promptedWorkspaceId },
this.logger
)
await api.testLogin().catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Login failed. Please check your credentials')
})
await this.writeProfileToFS(profileName, {
apiUrl: this.argv.apiUrl,
token: promptedToken,
workspaceId: promptedWorkspaceId,
})
this.logger.success('Logged In')
}
}
@@ -0,0 +1,10 @@
import type commandDefinitions from '../command-definitions'
import { GlobalCommand } from './global-command'
export type LogoutCommandDefinition = typeof commandDefinitions.logout
export class LogoutCommand extends GlobalCommand<LogoutCommandDefinition> {
public async run(): Promise<void> {
await this.globalCache.clear()
this.logger.success('Logged Out')
}
}
@@ -0,0 +1,95 @@
import type * as client from '@botpress/client'
import chalk from 'chalk'
import _ from 'lodash'
import type commandDefinitions from '../command-definitions'
import * as errors from '../errors'
import { parsePackageRef } from '../package-ref'
import { GlobalCommand } from './global-command'
export type GetPluginCommandDefinition = typeof commandDefinitions.plugins.subcommands.get
export class GetPluginCommand extends GlobalCommand<GetPluginCommandDefinition> {
public async run(): Promise<void> {
const api = await this.ensureLoginAndCreateClient(this.argv)
const parsedRef = parsePackageRef(this.argv.pluginRef)
if (!parsedRef) {
throw new errors.InvalidPackageReferenceError(this.argv.pluginRef)
}
if (parsedRef.type === 'path') {
throw new errors.BotpressCLIError('Cannot get local plugin')
}
try {
const plugin = await api.findPublicOrPrivatePlugin(parsedRef)
if (plugin) {
this.logger.success(`Plugin ${chalk.bold(this.argv.pluginRef)}:`)
this.logger.json(plugin)
return
}
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, `Could not get plugin ${this.argv.pluginRef}`)
}
throw new errors.BotpressCLIError(`Plugin ${this.argv.pluginRef} not found`)
}
}
export type ListPluginsCommandDefinition = typeof commandDefinitions.plugins.subcommands.list
export class ListPluginsCommand extends GlobalCommand<ListPluginsCommandDefinition> {
public async run(): Promise<void> {
const api = await this.ensureLoginAndCreateClient(this.argv)
const { name, versionNumber: version } = this.argv
const privateLister = (req: { nextToken?: string }) =>
api.client.listPlugins({ nextToken: req.nextToken, name, version })
const publicLister = (req: { nextToken?: string }) =>
api.client.listPublicPlugins({ nextToken: req.nextToken, name, version })
try {
const [privatePlugins, publicPlugins] = await Promise.all([
api.listAllPages(privateLister, (r) => r.plugins),
api.listAllPages(publicLister, (r) => r.plugins),
])
const plugins = _.uniqBy([...privatePlugins, ...publicPlugins], (p) => p.id)
this.logger.success('Plugins:')
this.logger.json(plugins)
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, 'Could not list plugins')
}
}
}
export type DeletePluginCommandDefinition = typeof commandDefinitions.plugins.subcommands.delete
export class DeletePluginCommand extends GlobalCommand<DeletePluginCommandDefinition> {
public async run(): Promise<void> {
const api = await this.ensureLoginAndCreateClient(this.argv)
const parsedRef = parsePackageRef(this.argv.pluginRef)
if (!parsedRef) {
throw new errors.InvalidPackageReferenceError(this.argv.pluginRef)
}
if (parsedRef.type === 'path') {
throw new errors.BotpressCLIError('Cannot delete local plugin')
}
let plugin: client.Plugin | undefined
try {
plugin = await api.findPublicOrPrivatePlugin(parsedRef)
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, `Could not get plugin ${this.argv.pluginRef}`)
}
if (!plugin) {
throw new errors.BotpressCLIError(`Plugin ${this.argv.pluginRef} not found`)
}
try {
await api.client.deletePlugin({ id: plugin.id })
} catch (thrown) {
throw errors.BotpressCLIError.wrap(thrown, `Could not delete plugin ${this.argv.pluginRef}`)
}
this.logger.success(`Plugin ${chalk.bold(this.argv.pluginRef)} deleted`)
return
}
}
@@ -0,0 +1,136 @@
import chalk from 'chalk'
import type commandDefinitions from '../command-definitions'
import * as consts from '../consts'
import * as errors from '../errors'
import * as utils from '../utils'
import { GlobalCache, GlobalCommand, ProfileCredentials } from './global-command'
type ProfileEntry = ProfileCredentials & {
name: string
}
type ProfileEntryWithoutToken = Omit<ProfileEntry, 'token'>
export type ActiveProfileCommandDefinition = typeof commandDefinitions.profiles.subcommands.active
export class ActiveProfileCommand extends GlobalCommand<ActiveProfileCommandDefinition> {
public async run(): Promise<void> {
let activeProfileName = await this.globalCache.get('activeProfile')
if (!activeProfileName) {
this.logger.debug(`No active profile set, defaulting to ${consts.defaultProfileName}`)
activeProfileName = consts.defaultProfileName
await this.globalCache.set('activeProfile', activeProfileName)
}
const profile = await this.readProfileFromFS(activeProfileName)
let profileEntry: ProfileEntry | ProfileEntryWithoutToken = { name: activeProfileName, ...profile }
if (_shouldOmitToken(this.argv)) profileEntry = _stripProfileToken(profileEntry)
this.logger.log('Active profile:')
this.logger.json(profileEntry)
}
}
export type ListProfilesCommandDefinition = typeof commandDefinitions.profiles.subcommands.list
export class ListProfilesCommand extends GlobalCommand<ListProfilesCommandDefinition> {
public async run(): Promise<void> {
const profiles = await this.readProfilesFromFS()
const profileEntries: ProfileEntry[] = Object.entries(profiles).map(([name, profile]) => ({ name, ...profile }))
if (!profileEntries.length) {
this.logger.log('No profiles found')
return
}
const activeProfileName = await this.globalCache.get('activeProfile')
const activeProfileMsg = `Active profile: '${chalk.bold(chalk.cyanBright(activeProfileName))}'`
this.logger.log(activeProfileMsg)
this.logger.json(!_shouldOmitToken(this.argv) ? profileEntries : profileEntries.map(_stripProfileToken))
this.logger.log(activeProfileMsg)
}
}
export type UseProfileCommandDefinition = typeof commandDefinitions.profiles.subcommands.use
export class UseProfileCommand extends GlobalCommand<UseProfileCommandDefinition> {
public async run(): Promise<void> {
const logSuccess = (profileName: string) => this.logger.success(`Now using profile "${profileName}"`)
if (this.argv.profileToUse) {
const profile = await this.readProfileFromFS(this.argv.profileToUse)
await this.globalCache.set('activeProfile', this.argv.profileToUse)
await _updateGlobalCache({ globalCache: this.globalCache, profileName: this.argv.profileToUse, profile })
logSuccess(this.argv.profileToUse)
return
}
const profiles = await this.readProfilesFromFS()
const choices = Object.keys(profiles).map((profileName) => ({
title: profileName,
description: '',
value: profileName,
}))
const selectedProfile = await this.prompt.select('Select the profile you want to use.', { choices })
if (!selectedProfile) {
this.logger.log('No profile selected, aborting.')
return
}
const profile = profiles[selectedProfile]
if (!profile) throw new errors.BotpressCLIError('The selected profile could not be read')
await this.globalCache.set('activeProfile', selectedProfile)
await _updateGlobalCache({ globalCache: this.globalCache, profileName: selectedProfile, profile })
logSuccess(selectedProfile)
}
}
export type GetProfileCommandDefinition = typeof commandDefinitions.profiles.subcommands.get
export class GetProfileCommand extends GlobalCommand<GetProfileCommandDefinition> {
public async run(): Promise<void> {
const logResult = (profileEntry: ProfileEntry) => {
const profile = _shouldOmitToken(this.argv) ? _stripProfileToken(profileEntry) : profileEntry
this.logger.log('Profile:')
this.logger.json(profile)
}
if (this.argv.profileToGet) {
const profile = await this.readProfileFromFS(this.argv.profileToGet)
logResult({ name: this.argv.profileToGet, ...profile })
return
}
const profiles = await this.readProfilesFromFS()
const choices = Object.keys(profiles).map((profileName) => ({
title: profileName,
description: '',
value: profileName,
}))
const selectedProfile = await this.prompt.select('Select the profile you want to get.', { choices })
if (!selectedProfile) {
this.logger.log('No profile selected, aborting.')
return
}
const profile = profiles[selectedProfile]
if (!profile) throw new errors.BotpressCLIError('The selected profile could not be read')
logResult({ name: selectedProfile, ...profile })
}
}
const _updateGlobalCache = async (props: {
globalCache: utils.cache.FSKeyValueCache<GlobalCache>
profileName: string
profile: ProfileCredentials
}): Promise<void> => {
await props.globalCache.set('activeProfile', props.profileName)
await props.globalCache.set('apiUrl', props.profile.apiUrl)
await props.globalCache.set('token', props.profile.token)
await props.globalCache.set('workspaceId', props.profile.workspaceId)
}
const _stripProfileToken = (profile: ProfileEntry | ProfileEntryWithoutToken): ProfileEntryWithoutToken => ({
name: profile.name,
apiUrl: profile.apiUrl,
workspaceId: profile.workspaceId,
})
const _shouldOmitToken = (argv: { displayToken: boolean }) => !argv.displayToken
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
import * as sdk from '@botpress/sdk'
import * as apiUtils from '../api'
import commandDefinitions from '../command-definitions'
import * as errors from '../errors'
import * as utils from '../utils'
import { ProjectCommand } from './project-command'
export type ReadCommandDefinition = typeof commandDefinitions.read
export class ReadCommand extends ProjectCommand<ReadCommandDefinition> {
public async run(): Promise<void> {
const { projectType, resolveProjectDefinition } = this.readProjectDefinitionFromFS()
if (projectType === 'integration') {
const projectDef = await resolveProjectDefinition()
const parsed = await this._parseIntegration(projectDef.definition)
this.logger.json(parsed)
return
}
if (projectType === 'interface') {
const projectDef = await resolveProjectDefinition()
const parsed = await this._parseInterface(projectDef.definition)
this.logger.json(parsed)
return
}
if (projectType === 'bot') {
const projectDef = await resolveProjectDefinition()
const parsed = await this._parseBot(projectDef.definition)
this.logger.json(parsed)
return
}
if (projectType === 'plugin') {
const projectDef = await resolveProjectDefinition()
const parsed = await this._parsePlugin(projectDef.definition)
this.logger.json(parsed)
return
}
throw new errors.BotpressCLIError('Unsupported project type')
}
private _parseIntegration = async (integrationDef: sdk.IntegrationDefinition) => {
const {
// Ignored fields ("code", "icon", "readme") so the terminal output doesn't get polluted
code: _code,
icon: _icon,
readme: _readme,
...parsed
} = await this.prepareCreateIntegrationBody(integrationDef)
parsed.interfaces = utils.records.mapValues(integrationDef.interfaces ?? {}, (iface) => ({
...iface,
id: iface.id ?? '',
}))
return parsed
}
private _parseInterface = async (interfaceDef: sdk.InterfaceDefinition) => {
return await apiUtils.prepareCreateInterfaceBody(interfaceDef)
}
private _parseBot = async (botDef: sdk.BotDefinition) => {
return await apiUtils.prepareCreateBotBody(botDef)
}
private _parsePlugin = async (pluginDef: sdk.PluginDefinition) => {
return await apiUtils.prepareCreatePluginBody(pluginDef)
}
}
@@ -0,0 +1,89 @@
import * as sdk from '@botpress/sdk'
import * as fslib from 'fs'
import type commandDefinitions from '../command-definitions'
import * as consts from '../consts'
import * as errors from '../errors'
import * as utils from '../utils'
import { BP_DEPENDENCIES_KEY, PKGJSON_FILE_NAME } from '../utils/pkgjson-utils'
import { ProjectCommand } from './project-command'
export type RemoveCommandDefinition = typeof commandDefinitions.remove
export class RemoveCommand extends ProjectCommand<RemoveCommandDefinition> {
protected async run(): Promise<void> {
const alias = this.argv.alias
if (!alias) {
throw new errors.BotpressCLIError('You have to provide the alias of the package to remove')
}
const workDir = utils.path.absoluteFrom(utils.path.cwd(), this.argv.workDir)
const { validatedBpDeps, pkgJson } = await this._validatePkgJson(workDir)
const correspondingPackageAlias = this._findCorrespondingPackage(alias, validatedBpDeps)
await this._removeDepFromBpModules(correspondingPackageAlias, workDir, alias)
await this._removeDepFromPkgJson(correspondingPackageAlias, validatedBpDeps, pkgJson, workDir, alias)
}
private async _validatePkgJson(workDir: string): Promise<{
validatedBpDeps: Record<string, string>
pkgJson: utils.pkgJson.PackageJson
}> {
const pkgJson = await utils.pkgJson.readPackageJson(workDir)
if (!pkgJson) {
throw new errors.BotpressCLIError(`No ${PKGJSON_FILE_NAME} found in path ${workDir}`)
}
const bpDependencies = pkgJson[BP_DEPENDENCIES_KEY]
if (!bpDependencies) {
throw new errors.BotpressCLIError('package')
}
const bpDependenciesSchema = sdk.z.record(sdk.z.string())
const parseResult = bpDependenciesSchema.safeParse(bpDependencies)
if (!parseResult.success) {
throw new errors.BotpressCLIError(`Invalid ${BP_DEPENDENCIES_KEY} found in ${PKGJSON_FILE_NAME}`)
}
return { validatedBpDeps: parseResult.data, pkgJson }
}
private async _removeDepFromBpModules(
correspondingPackageAlias: string,
workDir: utils.path.AbsolutePath,
alias: string
) {
const packageDirName = utils.casing.to.kebabCase(correspondingPackageAlias)
const installPath = utils.path.join(workDir, consts.installDirName, packageDirName)
await fslib.promises.rm(installPath, { force: true, recursive: true })
this.logger.log(`Package "${alias}" was removed from bp_modules`)
}
private async _removeDepFromPkgJson(
correspondingPackageAlias: string,
validatedBpDeps: Record<string, string>,
pkgJson: utils.pkgJson.PackageJson,
workDir: string,
alias: string
) {
const { [correspondingPackageAlias]: _, ...newBpDependencies } = validatedBpDeps
pkgJson[BP_DEPENDENCIES_KEY] = newBpDependencies
await utils.pkgJson.writePackageJson(workDir, pkgJson)
this.logger.log(`Package with alias "${alias}" was removed from ${PKGJSON_FILE_NAME}`)
}
private _findCorrespondingPackage(alias: string, bpDependencies: Record<string, string>): string {
const correspondingPackage: string | undefined = Object.keys(bpDependencies).find((key) => key === alias)
if (!correspondingPackage) {
throw new errors.BotpressCLIError(
`No corresponding package for alias "${alias}" was found in ${BP_DEPENDENCIES_KEY}`
)
}
return correspondingPackage
}
}
@@ -0,0 +1,43 @@
import type { Bot as BotImpl, Integration as IntegrationImpl } from '@botpress/sdk'
import * as fs from 'fs'
import type commandDefinitions from '../command-definitions'
import * as errors from '../errors'
import * as utils from '../utils'
import { ProjectCommand } from './project-command'
type Serveable = BotImpl | IntegrationImpl
export type ServeCommandDefinition = typeof commandDefinitions.serve
export class ServeCommand extends ProjectCommand<ServeCommandDefinition> {
public async run(): Promise<void> {
const outfile = this.projectPaths.abs.outFileCJS
if (!fs.existsSync(outfile)) {
throw new errors.NoBundleFoundError()
}
const { projectType, resolveProjectDefinition } = this.readProjectDefinitionFromFS()
if (projectType === 'interface') {
throw new errors.BotpressCLIError('An interface project has no implementation to serve.')
}
if (projectType === 'integration' || projectType === 'bot') {
const projectDef = await resolveProjectDefinition()
// TODO: store secrets in local cache to avoid prompting every time
const secretEnvVariables = await this.promptSecrets(projectDef.definition, this.argv, { formatEnv: true })
const nonNullSecretEnvVariables = utils.records.filterValues(secretEnvVariables, utils.guards.is.notNull)
for (const [key, value] of Object.entries(nonNullSecretEnvVariables)) {
process.env[key] = value
}
}
this.logger.log(`Serving ${projectType}...`)
const { default: serveable } = utils.require.requireJsFile<{ default: Serveable }>(outfile)
const server = await serveable.start(this.argv.port)
await new Promise<void>((resolve, reject) => {
server.on('error', reject)
server.on('close', resolve)
})
}
}