From e08d09f228d6c21436d7d660da7c2686dcefe503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oriol=20Ravent=C3=B3s?= <36898236+Iru89@users.noreply.github.com> Date: Tue, 1 Oct 2024 17:32:11 +0200 Subject: [PATCH] cli: send bot config to backend in botonic deploy #BLT-1008 (#2884) ## Description When deploying a bot the cli will get bot config from the @botonic packages installed, npm and node version . This config is send to backend in deploy endpoint. ## Context This config will be used to read bot information from the flow builder frontend. ## Approach taken / Explain the design PRs merged in this PR: - https://github.com/hubtype/botonic/pull/2895 - https://github.com/hubtype/botonic/pull/2894 - https://github.com/hubtype/botonic/pull/2892 - https://github.com/hubtype/botonic/pull/2885 --- CHANGELOG.md | 6 +- packages/botonic-cli/README.md | 27 +- packages/botonic-cli/package.json | 2 +- .../botonic-cli/src/botonic-api-service.ts | 313 ++++++++++-------- packages/botonic-cli/src/commands/deploy.ts | 209 ++++++------ packages/botonic-cli/src/interfaces.ts | 20 +- packages/botonic-cli/src/util/bot-config.ts | 108 ++++++ .../botonic-cli/tests/commands/deploy.test.ts | 14 +- 8 files changed, 440 insertions(+), 259 deletions(-) create mode 100644 packages/botonic-cli/src/util/bot-config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a586f149..537cce7bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,16 @@ All notable changes to Botonic will be documented in this file. Click to see more. -## [0.30.X] - aaaa-mm-dd +## [0.30.X] - 2024-10-03 ### Added ### Changed +- [@botonic/cli](https://www.npmjs.com/package/@botonic/cli) + + - [use new endpoint v2 to deploy bots](https://github.com/hubtype/botonic/pull/2884) + ### Fixed diff --git a/packages/botonic-cli/README.md b/packages/botonic-cli/README.md index 008fd46234..5868c489bb 100644 --- a/packages/botonic-cli/README.md +++ b/packages/botonic-cli/README.md @@ -8,14 +8,16 @@ Build Chatbots Using React [![License](https://img.shields.io/npm/l/@botonic/cli.svg)](https://github.com/hubtype/botonic/blob/master/package.json) -* [@botonic/cli](#botoniccli) -* [Usage](#usage) -* [Commands](#commands) + +- [@botonic/cli](#botoniccli) +- [Usage](#usage) +- [Commands](#commands) # Usage + ```sh-session $ npm install -g @botonic/cli $ botonic COMMAND @@ -27,19 +29,21 @@ USAGE $ botonic COMMAND ... ``` + # Commands -* [`botonic deploy [PROVIDER]`](#botonic-deploy-provider) -* [`botonic destroy [PROVIDER]`](#botonic-destroy-provider) -* [`botonic help [COMMAND]`](#botonic-help-command) -* [`botonic login`](#botonic-login) -* [`botonic logout`](#botonic-logout) -* [`botonic new NAME [PROJECTNAME]`](#botonic-new-name-projectname) -* [`botonic serve`](#botonic-serve) -* [`botonic test`](#botonic-test) + +- [`botonic deploy [PROVIDER]`](#botonic-deploy-provider) +- [`botonic destroy [PROVIDER]`](#botonic-destroy-provider) +- [`botonic help [COMMAND]`](#botonic-help-command) +- [`botonic login`](#botonic-login) +- [`botonic logout`](#botonic-logout) +- [`botonic new NAME [PROJECTNAME]`](#botonic-new-name-projectname) +- [`botonic serve`](#botonic-serve) +- [`botonic test`](#botonic-test) ## `botonic deploy [PROVIDER]` @@ -192,4 +196,5 @@ EXAMPLE ``` _See code: [lib/commands/test.js](https://github.com/hubtype/botonic/blob/v0.30.0/lib/commands/test.js)_ + diff --git a/packages/botonic-cli/package.json b/packages/botonic-cli/package.json index 40cd6fad27..ac1782c13f 100644 --- a/packages/botonic-cli/package.json +++ b/packages/botonic-cli/package.json @@ -10,7 +10,7 @@ "cloc": "../../scripts/qa/cloc-package.sh .", "prepublishOnly": "npm i && npm run build", "build": "rm -rf lib && ../../node_modules/.bin/tsc -p tsconfig.json", - "build:watch": "rm -rf lib && ./node_modules/.bin/tsc -w", + "build:watch": "rm -rf lib && ../../node_modules/.bin/tsc -w", "postpack": "rm -f oclif.manifest.json", "prepack": "oclif-dev manifest && oclif-dev readme", "version": "oclif-dev readme && git add README.md", diff --git a/packages/botonic-cli/src/botonic-api-service.ts b/packages/botonic-cli/src/botonic-api-service.ts index 334b4bb43a..e7606c3d38 100644 --- a/packages/botonic-cli/src/botonic-api-service.ts +++ b/packages/botonic-cli/src/botonic-api-service.ts @@ -1,41 +1,91 @@ -import axios, { AxiosPromise, Method } from 'axios' +/* eslint-disable @typescript-eslint/naming-convention */ +import axios, { + AxiosHeaders, + AxiosInstance, + AxiosPromise, + AxiosResponse, +} from 'axios' import childProcess from 'child_process' import colors from 'colors' import FormData from 'form-data' import { createReadStream, unlinkSync } from 'fs' import ora from 'ora' -import qs from 'qs' +import { stringify } from 'qs' import * as util from 'util' -import { BotInfo, OAuth } from './interfaces' - -const exec = util.promisify(childProcess.exec) import { BotCredentialsHandler, GlobalCredentialsHandler, } from './analytics/credentials-handler' +import { + AnalyticsInfo, + BotDetail, + BotListItem, + BotsList, + Me, + OAuth, +} from './interfaces' +import { BotConfigJSON } from './util/bot-config' import { pathExists } from './util/file-system' +const exec = util.promisify(childProcess.exec) + const BOTONIC_CLIENT_ID: string = process.env.BOTONIC_CLIENT_ID || 'jOIYDdvcfwqwSs7ZJ1CpmTKcE7UDapZDOSobFmEp' const BOTONIC_URL: string = process.env.BOTONIC_URL || 'https://api.hubtype.com' +interface RequestArgs { + apiVersion?: string + path: string + body?: any + headers?: any + params?: any +} export class BotonicAPIService { clientId: string = BOTONIC_CLIENT_ID baseUrl: string = BOTONIC_URL - baseApiUrl = this.baseUrl + '/v1/' loginUrl: string = this.baseUrl + '/o/token/' botCredentialsHandler = new BotCredentialsHandler() globalCredentialsHandler = new GlobalCredentialsHandler() oauth?: OAuth - me: any - analytics: any - bot: BotInfo | null - headers: Record | null = null + me?: Me + analytics: AnalyticsInfo + bot: BotDetail | null + headers: AxiosHeaders + apiClient: AxiosInstance constructor() { this.loadGlobalCredentials() this.loadBotCredentials() + this.setHeaders(this.oauth?.access_token) + + this.apiClient = axios.create({ + baseURL: BOTONIC_URL, + headers: this.headers, + }) + + const onFullfilled = (response: AxiosResponse) => { + return response + } + + const onRejected = async (error: any) => { + const originalRequest = error.config + const retry = originalRequest?._retry + + if (error.response?.status === 401 && !retry) { + originalRequest._retry = true + await this.refreshToken() + const nextRequest = { + ...originalRequest, + headers: this.headers, + } + + return this.apiClient.request(nextRequest) + } + return Promise.reject(error) + } + + this.apiClient.interceptors.response.use(onFullfilled, onRejected) } beforeExit(): void { @@ -43,7 +93,7 @@ export class BotonicAPIService { this.saveBotCredentials() } - botInfo(): BotInfo { + botInfo(): BotDetail { if (!this.bot) { throw new Error('Not bot info available') } @@ -57,36 +107,34 @@ export class BotonicAPIService { return this.oauth } - loadGlobalCredentials(): void { + private loadGlobalCredentials(): void { const credentials = this.globalCredentialsHandler.load() if (credentials) { this.oauth = credentials.oauth this.me = credentials.me this.analytics = credentials.analytics - if (this.oauth) { - this.headers = { - Authorization: `Bearer ${this.oauth.access_token}`, - 'content-type': 'application/json', - 'x-segment-anonymous-id': this.analytics.anonymous_id, - } - } } } - loadBotCredentials(): void { + private loadBotCredentials(): void { const credentials = this.botCredentialsHandler.load() - if (credentials) { - // eslint-disable-next-line no-prototype-builtins - if (credentials.hasOwnProperty('bot')) { - this.bot = credentials.bot - } else { - // Allow users < v0.1.12 to upgrade smoothly - this.bot = credentials as any as BotInfo - } + + if (credentials?.bot) { + this.bot = credentials.bot } } - saveGlobalCredentials(): void { + private setHeaders(accessToken?: string) { + if (accessToken) { + this.headers = new AxiosHeaders({ + Authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + 'x-segment-anonymous-id': this.analytics.anonymous_id, + }) + } + } + + private saveGlobalCredentials(): void { this.globalCredentialsHandler.createDirIfNotExists() this.globalCredentialsHandler.dump({ oauth: this.oauth, @@ -95,7 +143,7 @@ export class BotonicAPIService { }) } - saveBotCredentials(): void { + private saveBotCredentials(): void { this.botCredentialsHandler.dump({ bot: this.bot, }) @@ -129,175 +177,180 @@ export class BotonicAPIService { if (pathExists(pathToCredentials)) unlinkSync(pathToCredentials) } - async api( - path: string, - body: any = null, - method: Method = 'get', - headers: any = null, - params: any = null - ): Promise { - let b = 0 - try { - return await axios({ - method: method, - url: this.baseApiUrl + path, - headers: headers || this.headers, - data: body, - params: params, - }) - } catch (e: any) { - if (e.response.status == 401) { - b = 1 - } else { - return e - } - } - if (b == 1) { - await this.refreshToken() - } - return axios({ - method: method, - url: this.baseApiUrl + path, + private async apiPost({ + apiVersion = 'v1', + path, + body, + headers, + params, + }: RequestArgs): Promise { + return this.apiClient.post(`${this.baseUrl}/${apiVersion}/${path}`, body, { + headers: headers || this.headers, + params, + }) + } + + private async apiGet({ + apiVersion = 'v1', + path, + headers, + params, + }: RequestArgs): Promise { + return this.apiClient.get(`${this.baseUrl}/${apiVersion}/${path}`, { headers: headers || this.headers, - data: body, - params: params, + params, }) } - async refreshToken(): Promise { - const data = qs.stringify({ + private async refreshToken(): Promise { + const data = stringify({ callback: 'none', grant_type: 'refresh_token', refresh_token: this.getOauth().refresh_token, client_id: this.clientId, }) - const resp = await axios({ + const oauthResponse = await axios.post(this.loginUrl, data, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, - method: 'post', - url: this.loginUrl, - data: data, }) - if (!resp) return - this.oauth = resp.data - this.headers = { - Authorization: `Bearer ${this.getOauth().access_token}`, - 'content-type': 'application/json', - 'x-segment-anonymous-id': this.analytics.anonymous_id, + + if (oauthResponse.status !== 200) { + throw new Error('Error refreshing token') } + this.oauth = oauthResponse.data + + const accessToken = this.getOauth().access_token + this.setHeaders(accessToken) this.saveGlobalCredentials() - // eslint-disable-next-line consistent-return - return resp } - async login(email: string, password: string): Promise { - const data = qs.stringify({ + async login(email: string, password: string): Promise { + const data = stringify({ username: email, password: password, grant_type: 'password', client_id: this.clientId, }) - let resp = await axios({ + const loginResponse = await axios.post(this.loginUrl, data, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, - method: 'post', - url: this.loginUrl, - data: data, }) - this.oauth = resp.data - this.headers = { - Authorization: `Bearer ${this.getOauth().access_token}`, - 'content-type': 'application/json', - 'x-segment-anonymous-id': this.analytics.anonymous_id, + this.oauth = loginResponse.data + + const accessToken = this.getOauth().access_token + this.setHeaders(accessToken) + + const meResponse = await this.apiGet({ path: 'users/me' }) + if (meResponse) { + this.me = meResponse.data } - resp = await this.api('users/me') - if (resp) this.me = resp.data - return resp } signup( email: string, password: string, - org_name: string, + orgName: string, campaign: any ): Promise { - const url = `${this.baseApiUrl}users/` - const signup_data = { email, password, org_name, campaign } - return axios({ method: 'post', url: url, data: signup_data }) + const signupData = { email, password, org_name: orgName, campaign } + return this.apiPost({ path: 'users/', body: signupData }) } - async saveBot(botName: string): Promise { - const resp = await this.api('bots/', { name: botName }, 'post') - if (resp.data) this.setCurrentBot(resp.data) + async createBot(botName: string): Promise { + const resp = await this.apiPost({ + apiVersion: 'v2', + path: 'bots/', + body: { name: botName }, + }) + + if (resp.data) { + this.setCurrentBot(resp.data) + } + return resp } - async getMe(): Promise { - return this.api('users/me/') + private async getMe(): AxiosPromise { + return this.apiGet({ path: 'users/me/' }) } - async getBots(): Promise { - return this.api('bots/bots_paginator/', null, 'get', null, { - organization_id: this.me.organization_id, - }) + async getBots(): AxiosPromise { + const botsResponse = await this.apiGet({ apiVersion: 'v2', path: 'bots/' }) + + if (botsResponse.data.next) { + this.getMoreBots(botsResponse.data.results, botsResponse.data.next) + } + + return botsResponse } - async getMoreBots(bots: any, nextBots: any) { - if (!nextBots) return bots - const resp = await this.api( - nextBots.split(this.baseApiUrl)[1], - null, - 'get', - null - ) + private async getMoreBots(bots: BotListItem[], nextBots?: string) { + if (!nextBots) { + return bots + } + + const resp = await this.apiGet({ + apiVersion: 'v2', + path: nextBots.split(`${this.baseUrl}/v2/`)[1], + }) resp.data.results.map(b => bots.push(b)) nextBots = resp.data.next + return this.getMoreBots(bots, nextBots) } async getProviders(): Promise { - return this.api('provider_accounts/', null, 'get', null, { - bot_id: this.botInfo().id, + return this.apiGet({ + path: 'provider_accounts/', + params: { + bot_id: this.botInfo().id, + }, }) } - async deployBot(bundlePath: string): Promise { + async deployBot( + bundlePath: string, + botConfigJson: BotConfigJSON + ): Promise { try { - const _authenticated = await this.getMe() + await this.getMe() } catch (e) { console.log(`Error authenticating: ${String(e)}`) } + const form = new FormData() const data = createReadStream(bundlePath) form.append('bundle', data, 'botonic_bundle.zip') + form.append('bot_config', JSON.stringify(botConfigJson)) const headers = await this.getHeaders(form) - return await this.api( - `bots/${this.botInfo().id}/deploy_botonic_new/`, - form, - 'post', - { ...this.headers, ...headers }, - { version: '0.7' } - ) + + return await this.apiPost({ + apiVersion: 'v2', + path: `bots/${this.botInfo().id}/deploy/`, + body: form, + headers: { + ...this.headers, + ...headers, + }, + }) } async deployStatus(deployId: string): Promise { - return this.api( - `bots/${this.botInfo().id}/deploy_botonic_status/`, - null, - 'get', - null, - { deploy_id: deployId } - ) + return this.apiGet({ + apiVersion: 'v2', + path: `bots/${this.botInfo().id}/deploy_status/`, + params: { deploy_id: deployId }, + }) } - async getHeaders(form: any): Promise> { + private async getHeaders(form: FormData): Promise> { //https://github.com/axios/axios/issues/1006#issuecomment-352071490 return new Promise((resolve, reject) => { - form.getLength((err: any, length: any) => { + form.getLength((err: any, length: number) => { if (err) { reject(err) } diff --git a/packages/botonic-cli/src/commands/deploy.ts b/packages/botonic-cli/src/commands/deploy.ts index 7e0237db5d..bd12f8893a 100644 --- a/packages/botonic-cli/src/commands/deploy.ts +++ b/packages/botonic-cli/src/commands/deploy.ts @@ -12,6 +12,7 @@ import { ZipAFolder } from 'zip-a-folder' import { Telemetry } from '../analytics/telemetry' import { BotonicAPIService } from '../botonic-api-service' import { CLOUD_PROVIDERS } from '../constants' +import { BotConfig, BotConfigJSON } from '../util/bot-config' import { copy, createDir, @@ -101,13 +102,10 @@ Deploying to AWS... async deployBotFromFlag(botName: string): Promise { const resp = await this.botonicApiService.getBots() - const nextBots = resp.data.next const bots = resp.data.results - if (nextBots) { - await this.botonicApiService.getMoreBots(bots, nextBots) - } + const bot = bots.filter(b => b.name === botName)[0] - if (bot == undefined && !botName) { + if (bot === undefined && !botName) { console.log(colors.red(`Bot ${botName} doesn't exist.`)) console.log('\nThese are the available options:') bots.map(b => console.log(` > ${String(b.name)}`)) @@ -119,29 +117,28 @@ Deploying to AWS... this.botonicApiService.setCurrentBot(bot) return await this.deploy() } - return prompt([ + const res = await prompt([ { type: 'confirm', name: 'create_bot_confirm', message: 'Do you want to create a new Bot?', }, - ]).then((res: any) => { - const confirm = res.create_bot_confirm - if (confirm) return this.createNewBot(botName) - return undefined - }) + ]) + const confirm = res.create_bot_confirm + if (confirm) return this.createNewBot(botName) + return undefined } else { this.botonicApiService.setCurrentBot(bot) return await this.deploy() } } - signupFlow(): Promise { + async signupFlow(): Promise { const choices = [ 'No, I need to create a new one (Signup)', 'Yes, I do. (Login)', ] - return prompt([ + const inp = await prompt([ { type: 'list', name: 'signupConfirmation', @@ -149,13 +146,12 @@ Deploying to AWS... 'You need to login before deploying your bot.\nDo you have a Hubtype account already?', choices: choices, }, - ]).then((inp: any) => { - if (inp.signupConfirmation == choices[1]) return this.askLogin() - else return this.askSignup() - }) + ]) + if (inp.signupConfirmation == choices[1]) return this.askLogin() + else return this.askSignup() } - askEmailPassword(): Promise<{ email: string; password: string }> { + async askEmailPassword(): Promise<{ email: string; password: string }> { return prompt([ { type: 'input', @@ -172,15 +168,13 @@ Deploying to AWS... } async askLogin(): Promise { - await this.askEmailPassword().then(inp => - this.login(inp.email, inp.password) - ) + const inp = await this.askEmailPassword() + this.login(inp.email, inp.password) } async askSignup(): Promise { - await this.askEmailPassword().then(inp => - this.signup(inp.email, inp.password) - ) + const inp = await this.askEmailPassword() + this.signup(inp.email, inp.password) } async deployBotFlow(): Promise { @@ -192,74 +186,65 @@ Deploying to AWS... return this.newBotFlow() else { const resp = await this.botonicApiService.getBots() - const nextBots = resp.data.next const bots = resp.data.results - if (nextBots) await this.botonicApiService.getMoreBots(bots, nextBots) + // Show the current bot in credentials at top of the list - const first_id = this.botonicApiService.bot.id - bots.sort(function (x, y) { - return x.id == first_id ? -1 : y.id == first_id ? 1 : 0 - }) + const firstId = this.botonicApiService.bot.id + bots.sort((x, y) => (x.id === firstId ? -1 : y.id === firstId ? 1 : 0)) return this.selectExistentBot(bots) } } async login(email: string, password: string): Promise { - return this.botonicApiService.login(email, password).then( - ({}) => this.deployBotFlow(), - async (err: AxiosError) => { - if ( - err.response && - err.response.data && - err.response.data.error_description - ) { - console.log(colors.red(err.response.data.error_description)) - } else { - console.log( - colors.red( - 'There was an error when trying to log in. Please, try again:' - ) + try { + await this.botonicApiService.login(email, password) + await this.deployBotFlow() + } catch (err: any) { + const axiosError = err as AxiosError + if (axiosError.response?.data?.error_description) { + console.log(colors.red(axiosError.response.data.error_description)) + } else { + console.log( + colors.red( + 'There was an error when trying to log in. Please, try again:' + ) + ) + if (axiosError.response?.status && axiosError.response?.statusText) { + console.error( + `Error ${axiosError.response.status}: ${axiosError.response.statusText}` ) - if (err.response && err.response.status && err.response.statusText) { - console.error( - `Error ${err.response.status}: ${err.response.statusText}` - ) - } } - await this.askLogin() } - ) + await this.askLogin() + } } async signup(email: string, password: string): Promise { - const org_name = email.split('@')[0] + const orgName = email.split('@')[0] const campaign = { product: 'botonic' } - return this.botonicApiService - .signup(email, password, org_name, campaign) - .then( - ({}) => this.login(email, password), - async err => { - if (err.response.data.email) - console.log(colors.red(err.response.data.email[0])) - if (err.response.data.password) - console.log(colors.red(err.response.data.password[0])) - if (!err.response.data.email && !err.response.data.password) - console.log( - colors.red( - 'There was an error trying to signup. Please, try again:' - ) - ) - await this.askSignup() - } - ) + try { + await this.botonicApiService.signup(email, password, orgName, campaign) + await this.login(email, password) + } catch (err: any) { + if (err.response?.data?.email) { + console.log(colors.red(err.response.data.email[0])) + } + if (err.response?.data?.password) { + console.log(colors.red(err.response.data.password[0])) + } + if (!err.response?.data?.email && !err.response?.data?.password) { + console.log( + colors.red('There was an error trying to signup. Please, try again:') + ) + } + await this.askSignup() + } } async getAvailableBots(): Promise { const resp = await this.botonicApiService.getBots() - const nextBots = resp.data.next - const bots = resp.data.results - if (nextBots) await this.botonicApiService.getMoreBots(bots, nextBots) - return bots + + return resp.data.results } async newBotFlow(): Promise { @@ -267,65 +252,64 @@ Deploying to AWS... if (!bots.length) { return this.createNewBot() } else { - return prompt([ + const res = await prompt([ { type: 'confirm', name: 'create_bot_confirm', message: 'Do you want to create a new Bot?', }, - ]).then((res: any) => { - const confirm = res.create_bot_confirm - if (confirm) { - return this.createNewBot() - } else { - return this.selectExistentBot(bots) - } - }) + ]) + const confirm = res.create_bot_confirm + if (confirm) { + return this.createNewBot() + } else { + return this.selectExistentBot(bots) + } } } - createNewBotWithName(inpBotName: string): Promise { + async createNewBotWithName(inpBotName: string): Promise { const MAX_ALLOWED_CHARS_FOR_BOT_NAME = 25 if (inpBotName.length > MAX_ALLOWED_CHARS_FOR_BOT_NAME) { throw new Error( `Maximum allowed chars for bot name is ${MAX_ALLOWED_CHARS_FOR_BOT_NAME} chars. Please, give a shorter name.` ) } - return this.botonicApiService.saveBot(inpBotName).then( - ({}) => this.deploy(), - err => - console.log( - colors.red(`There was an error saving the bot: ${String(err)}`) - ) - ) + + try { + await this.botonicApiService.createBot(inpBotName) + this.deploy() + } catch (err: any) { + console.log( + colors.red(`There was an error saving the bot: ${String(err)}`) + ) + } } - createNewBot(botName?: string): Promise { + async createNewBot(botName?: string): Promise { if (botName) return this.createNewBotWithName(botName) - return prompt([ + const inp = await prompt([ { type: 'input', name: 'bot_name', message: 'Bot name:', }, - ]).then((inp: any) => { - return this.createNewBotWithName(inp.bot_name) - }) + ]) + return await this.createNewBotWithName(inp.bot_name) } - selectExistentBot(bots: any[]): Promise { - return prompt([ + async selectExistentBot(bots: any[]): Promise { + const inp = await prompt([ { type: 'list', name: 'bot_name', message: 'Please, select a bot', choices: bots.map(b => b.name), }, - ]).then((inp: { bot_name: string }) => { - const bot = bots.filter(b => b.name === inp.bot_name)[0] - this.botonicApiService.setCurrentBot(bot) - return this.deploy() - }) + ]) + const bot = bots.filter(b_1 => b_1.name === inp.bot_name)[0] + this.botonicApiService.setCurrentBot(bot) + return await this.deploy() } displayProviders(providers: { username: string; provider: string }[]): void { @@ -375,19 +359,19 @@ Deploying to AWS... } /* istanbul ignore next */ - async deployBundle(): Promise<{ hasDeployErrors: boolean }> { + async deployBundle( + botConfigJson: BotConfigJSON + ): Promise<{ hasDeployErrors: boolean }> { const spinner = ora({ text: 'Deploying...', spinner: 'bouncingBar', }).start() try { const deploy = await this.botonicApiService.deployBot( - join(process.cwd(), BOTONIC_BUNDLE_FILE) + join(process.cwd(), BOTONIC_BUNDLE_FILE), + botConfigJson ) - if ( - (deploy.response && deploy.response.status == 403) || - !deploy.data.deploy_id - ) { + if (deploy.response?.status === 403 || !deploy.data.deploy_id) { const error = `Deploy Botonic Error: ${String( deploy.response.data.status )}` @@ -408,7 +392,7 @@ Deploying to AWS... } else throw deployStatus.data.error } } - } catch (err) { + } catch (err: any) { spinner.fail() const error = String(err) console.log(colors.red('There was a problem in the deploy:')) @@ -455,8 +439,11 @@ Deploying to AWS... console.log(colors.red('There was a problem building the bot')) return } + + const botConfigJson = await BotConfig.get(process.cwd()) + await this.createBundle() - const { hasDeployErrors } = await this.deployBundle() + const { hasDeployErrors } = await this.deployBundle(botConfigJson) await this.displayDeployResults({ hasDeployErrors }) } catch (e) { console.log(colors.red('Deploy Error'), e) diff --git a/packages/botonic-cli/src/interfaces.ts b/packages/botonic-cli/src/interfaces.ts index eb2f03e9c3..2f44a14c89 100644 --- a/packages/botonic-cli/src/interfaces.ts +++ b/packages/botonic-cli/src/interfaces.ts @@ -18,7 +18,7 @@ export interface OAuth { refresh_token: string } -interface Me { +export interface Me { id: string username: string email: string @@ -43,7 +43,7 @@ interface Me { managers_settings_json: any } -interface AnalyticsInfo { +export interface AnalyticsInfo { anonymous_id: string } @@ -53,6 +53,18 @@ export interface GlobalCredentials { analytics: AnalyticsInfo } +export interface BotsList { + count: number + next: string + previous: string + results: BotListItem[] +} + +export interface BotListItem { + id: string + name: string +} + interface BotLastUpdate { version: string created_at: string @@ -61,7 +73,7 @@ interface BotLastUpdate { comment: string } -export interface BotInfo { +export interface BotDetail { id: string name: string organization: string @@ -109,7 +121,7 @@ interface ProviderAccountsInfo { } export interface BotCredentials { - bot: BotInfo | null + bot: BotDetail | null } export interface TrackArgs { diff --git a/packages/botonic-cli/src/util/bot-config.ts b/packages/botonic-cli/src/util/bot-config.ts new file mode 100644 index 0000000000..326f15d14b --- /dev/null +++ b/packages/botonic-cli/src/util/bot-config.ts @@ -0,0 +1,108 @@ +import childProcess from 'child_process' +import { promises as fs } from 'fs' +import ora from 'ora' +import path from 'path' +import * as util from 'util' + +const BOTONIC_PREFIX_PACKAGE = '@botonic' +const BOTONIC_CORE_PACKAGE = `${BOTONIC_PREFIX_PACKAGE}/core` +const botonicCliVersionRegex = /@botonic\/cli\/([\d.]+-[\w.]+)/ +const NPM_DEPTH_1 = 1 +const NPM_DEPTH_0 = 0 + +// Also get alpha and beta versions +const versionRegex = /@botonic\/[^@]+@(\d+\.\d+\.\d+(-[a-zA-Z]+\.\d+)?)/ + +type BotonicDependencies = Record + +export interface BotConfigJSON { + build_info: { + node_version: string + npm_version: string + botonic_cli_version: string + } + packages: BotonicDependencies +} +export class BotConfig { + static async get(appDirectory: string): Promise { + const spinner = ora({ + text: 'Getting bot config...', + spinner: 'bouncingBar', + }).start() + const packages = await this.getBotonicDependencies(appDirectory) + const [nodeVersion, npmVersion, botonicCli] = await Promise.all( + ['node -v', 'npm -v', 'botonic -v'].map(command => + this.getOutputByCommand(command) + ) + ) + const botonicCliVersion = + botonicCli.match(botonicCliVersionRegex)?.[1] || '' + spinner.succeed() + + return { + build_info: { + node_version: nodeVersion, + npm_version: npmVersion, + botonic_cli_version: botonicCliVersion, + }, + packages, + } + } + + private static async getBotonicDependencies( + appDirectory: string + ): Promise { + const packages = {} + try { + const packageJsonPath = path.join(appDirectory, 'package.json') + const data = await fs.readFile(packageJsonPath, 'utf8') + const packageJson = JSON.parse(data) + + const botonicDependencies = Object.keys(packageJson.dependencies).filter( + dependency => dependency.startsWith(BOTONIC_PREFIX_PACKAGE) + ) + if (!botonicDependencies.includes(BOTONIC_CORE_PACKAGE)) { + botonicDependencies.push(BOTONIC_CORE_PACKAGE) + } + + await Promise.all( + botonicDependencies.map(botonicDependency => { + return this.setDependenciesVersion( + botonicDependency, + packages, + botonicDependency === BOTONIC_CORE_PACKAGE + ? NPM_DEPTH_1 + : NPM_DEPTH_0 + ) + }) + ) + } catch (err: any) { + console.error(`Error: ${err.message}`) + } + return packages + } + + private static async setDependenciesVersion( + dependency: string, + packages: Record, + depth: number = NPM_DEPTH_0 + ): Promise { + try { + const output = await this.getOutputByCommand( + `npm ls ${dependency} --depth=${depth}` + ) + const match = output.match(versionRegex) + const installedVersion = match ? match[1] : '' + packages[dependency] = { version: installedVersion } + } catch (error: any) { + console.error(error) + } + return packages + } + + private static async getOutputByCommand(command: string): Promise { + const exec = util.promisify(childProcess.exec) + const { stdout } = await exec(command) + return stdout.trim() + } +} diff --git a/packages/botonic-cli/tests/commands/deploy.test.ts b/packages/botonic-cli/tests/commands/deploy.test.ts index 5d2605f2fb..ab3956c520 100644 --- a/packages/botonic-cli/tests/commands/deploy.test.ts +++ b/packages/botonic-cli/tests/commands/deploy.test.ts @@ -43,7 +43,19 @@ describe('TEST: Deploy pipeline', () => { const onceBundled = readDir('.') expect(onceBundled).toContain('botonic_bundle.zip') expect(onceBundled).toContain('tmp') - const { hasDeployErrors } = await deployCommand.deployBundle() + const botConfigJson = { + build_info: { + node_version: 'v20.0.0', + npm_version: '10.0.0', + botonic_cli_version: '0.29.0', + }, + packages: { + '@botonic/core': { version: '0.29.0' }, + '@botonic/react': { version: '0.29.0' }, + }, + } + const { hasDeployErrors } = + await deployCommand.deployBundle(botConfigJson) expect(hasDeployErrors).toBe(false) removeRecursively(join('.', 'botonic_bundle.zip')) removeRecursively(join('.', 'tmp'))