kchatbotbak
Version:
kchatbotbak is base on Botfront.
398 lines (371 loc) • 15.3 kB
JavaScript
import shell from 'shelljs';
import open from 'open';
import { Docker } from 'docker-cli-js';
import ora from 'ora';
import chalk from 'chalk';
import inquirer from 'inquirer';
import boxen from 'boxen';
import { pullDockerImages, copyTemplateFilesToProjectDir } from './init';
import path from 'path';
import { watch } from 'chokidar';
import {
fixDir,
isProjectDir,
getMissingImgs,
waitForService,
getServiceUrl,
wait,
shellAsync,
getServiceNames,
capitalize,
getComposeTemplateFile,
getGeneratedComposeFile,
generateDockerCompose,
startSpinner,
stopSpinner,
failSpinner,
succeedSpinner,
consoleError,
setSpinnerText,
setSpinnerInfo,
updateProjectFile,
updateEnvFile,
displayNpmUpdateMessage as displayNpmUpdateMessage,
getDefaultServiceNames,
isMajorUpdateWithVersion,
isMinorUpdateWithVersion,
getBotfrontVersion,
getProjectVersion,
displayProjectUpdateMessage,
getMongoPassword,
} from '../utils';
async function postUpLaunch(spinner) {
const serviceUrl = getServiceUrl('botfront');
setSpinnerText(
spinner,
`Opening kchatbotbak (${chalk.green.bold(serviceUrl)}) in your browser...`,
);
await wait(2000);
await open(serviceUrl);
setSpinnerInfo(spinner, `Visit ${chalk.green(serviceUrl)}`);
console.log('\n');
}
async function removeDynamicallyBuiltImages() {
const folderName = path.basename(fixDir());
const docker = new Docker({});
try {
await docker.command(`rmi ${folderName}_rasa ${folderName}_actions`);
} catch (e) {
// Do nothing - just in case the images do not exists
}
}
export async function doMinorUpdate() {
const botfrontVersion = getBotfrontVersion();
const projectVersion = getProjectVersion();
const mongoPassword = getMongoPassword();
if (isMajorUpdateWithVersion(projectVersion, botfrontVersion)) {
return console.log(
boxen(
`Project was made with kchatbotbak ${chalk.blueBright(
projectVersion,
)} and the currently installed version is ${chalk.green(
botfrontVersion,
)}, which is a major update.\nPlease follow the instructions in the migration guide: ${chalk.cyan.bold(
'https://xxx.com',
)}.`,
),
);
}
if (isMinorUpdateWithVersion(projectVersion, botfrontVersion)) {
await copyTemplateFilesToProjectDir(fixDir(), {}, true, true, mongoPassword);
removeDynamicallyBuiltImages();
return console.log(boxen('Your project was updated successfully 👌.'));
}
return console.log(boxen('Everything is up to date 👌.'));
}
export async function dockerComposeUp(
{ verbose = false, exclude = [], ci = false },
workingDir,
spinner,
) {
spinner = spinner ? spinner : ci ? null : ora();
await displayNpmUpdateMessage();
const projectAbsPath = fixDir(workingDir);
shell.cd(projectAbsPath);
if (!isProjectDir()) {
const noProjectMessage = `${chalk.yellow.bold(
'No project found.',
)} ${chalk.cyan.bold(
'kchatbotbak up',
)} must be executed from your project's directory`;
return console.log(boxen(noProjectMessage));
}
const isMajorUpdateRequired = await displayProjectUpdateMessage();
if (isMajorUpdateRequired) process.exit(0);
updateEnvFile(process.cwd());
await generateDockerCompose(exclude);
startSpinner(spinner, 'Starting kchatbotbak...');
const missingImgs = await getMissingImgs();
await pullDockerImages(missingImgs, spinner, 'Downloading Docker images...');
await stopRunningProjects(
'Shutting down running project first...',
null,
null,
spinner,
);
let command = 'docker-compose up -d';
const services = getServiceNames(workingDir);
try {
startSpinner(spinner, 'Starting kchatbotbak...');
await shellAsync(command, { silent: !verbose });
if (ci) process.exit(0); // exit now if ci
if (services.includes('botfront') && !exclude.includes('botfront'))
await waitForService('botfront');
stopSpinner();
console.log(`\n\n 🎉 🎈 kchatbotbak is ${chalk.green.bold('UP')}! 🎉 🎈\n`);
const message =
'Useful commands:\n\n' +
(`\u2022 Run ${chalk.cyan.bold('kchatbotbak logs')} to follow logs \n` +
`\u2022 Run ${chalk.cyan.bold(
'kchatbotbak watch',
)} to watch ${chalk.yellow.bold('actions')} and ${chalk.yellow.bold(
'rasa',
)} folders (see ` +
`${chalk.cyan.bold('https://xxx.com')})\n` +
`\u2022 Run ${chalk.cyan.bold('kchatbotbak down')} to stop kchatbotbak\n` +
`\u2022 Run ${chalk.cyan.bold(
'kchatbotbak --help',
)} to get help with the CLI\n`);
console.log(boxen(message) + '\n');
if (services.includes('botfront') && !exclude.includes('botfront'))
await postUpLaunch(spinner); // browser stuff is botfront is not excluded
stopSpinner();
process.exit(0);
} catch (e) {
if (verbose) {
failSpinner(
spinner,
`${chalk.red.bold(
'ERROR:',
)} Something went wrong. Check the logs above for more information ☝️, or try inspecting the logs with ${chalk.red.cyan(
'kchatbotbak logs',
)}.`,
);
console.log(e);
} else {
stopSpinner(spinner);
failSpinner(spinner, 'Couldn\'t start kchatbotbak. Retrying in verbose mode...', {
exit: false,
});
return dockerComposeUp(
{ verbose: true, exclude: exclude },
workingDir,
null,
spinner,
);
}
}
}
export async function dockerComposeDown({ verbose }, workingDir) {
if (workingDir) shell.cd(workingDir);
if (!isProjectDir()) {
const noProject = chalk.yellow.bold('No project found in this directory.');
const killall = chalk.cyan.bold('kchatbotbak killall');
const noProjectMessage =
`${noProject}\n\nIf you don't know where your project is running from,\n` +
`${killall} will find and shut down any kchatbotbak\nproject on your machine.`;
return console.log(boxen(noProjectMessage));
}
const spinner = ora('Stopping kchatbotbak...');
spinner.start();
let command = 'docker-compose down';
await shellAsync(command, { silent: !verbose });
spinner.succeed('All services are stopped. Come back soon... 🤗');
}
export async function dockerComposeStop(service, { verbose }, workingDir) {
await dockerComposeCommand(
service,
{ name: 'stop', action: 'stopping' },
verbose,
workingDir,
).catch(consoleError);
}
export async function setProject(bf_project_id) {
if (!bf_project_id || typeof bf_project_id !== 'string') {
throw new Error(`Project ID '${bf_project_id}' is not valid.`);
}
const { services } = getComposeTemplateFile();
const { services: actualServices } = getGeneratedComposeFile();
const exclude = Object.keys(services).filter((k) => !(k in actualServices));
updateProjectFile({ leaveMongoUrl: true, env: { bf_project_id } });
// generate compose file without touching .env file
generateDockerCompose(exclude, undefined, bf_project_id);
const spinner = ora(
`Restarting Rasa and action server to serve project '${bf_project_id}'...`,
);
spinner.start();
await shellAsync('docker-compose up -d', { silent: true });
spinner.succeed(`Successfully switched to project '${bf_project_id}'.`);
}
export async function dockerComposeStart(service, { verbose }, workingDir) {
await dockerComposeCommand(
service,
{ name: 'start', action: 'starting' },
verbose,
workingDir,
'It might take a few seconds before services are available...',
).catch(consoleError);
}
export async function dockerComposeRestart(service, { verbose }, workingDir) {
await dockerComposeCommand(
service,
{ name: 'restart', action: 'restarting' },
verbose,
workingDir,
'It might take a few seconds before services are available...',
).catch(consoleError);
}
export async function dockerComposeBuildAndRestart(service, { verbose }) {
let command = `docker-compose up -d --force-recreate --build ${service}`;
await shellAsync(command, { silent: verbose }).catch(consoleError);
}
export async function dockerComposeCommand(
service,
{ name, action },
verbose,
workingDir,
message = '',
) {
if (workingDir) shell.cd(workingDir);
if (!isProjectDir()) {
const noProjectMessage =
`${chalk.yellow.bold('No project found in this directory.')}\n` +
(`${chalk.cyan.bold(
`kchatbotbak ${name}`,
)} must be executed in your project's directory.\n` +
`${chalk.green.bold(
'TIP: ',
)}if you just created your project, you probably just have to` +
` do ${chalk.cyan.bold('cd <your-project-folder>')} and then retry.`);
return console.log(boxen(noProjectMessage));
}
const allowedServices = getServiceNames(workingDir);
let services = [service];
let regeneratedDockerCompose = false;
if (!service || !allowedServices.includes(service)) {
const defaultServices = getDefaultServiceNames(workingDir);
if (name === 'start' && defaultServices.includes(service)) {
// service had been excluded, regenerate docker compose file
const exclude = defaultServices.filter(
(s) => ![...allowedServices, service].includes(s),
);
regeneratedDockerCompose = await generateDockerCompose(exclude);
} else {
const choices = allowedServices.concat(`${capitalize(name)} all services`);
const { serv } = await inquirer.prompt({
type: 'list',
name: 'serv',
message: `Which service do you want to ${name}?`,
choices,
});
services = serv.endsWith('all services') ? allowedServices : [serv];
}
}
const spinner = ora(`${capitalize(action)} ${services.join(', ')}...`);
spinner.start();
const command = regeneratedDockerCompose // if docker-compose file has been regenerated, run 'up -d' instead of 'start', to create container
? 'docker-compose up -d --force-recreate'
: `docker-compose ${name} ${services.join(' ')}`;
await shellAsync(command, { silent: !verbose });
spinner.succeed(`Done. ${message}`);
}
export function dockerComposeFollow({ ci = false }, workingDir) {
if (workingDir) shell.cd(workingDir);
if (!isProjectDir()) {
const noProjectMessage =
`${chalk.yellow.bold(
'No project found in this directory.',
)}\nThis command must be executed in your project's root directory.\n` +
`${chalk.green.bold(
'TIP: ',
)}if you just created your project, you probably just have to do ${chalk.cyan.bold(
'cd <your-project-folder>',
)} and then retry`;
return console.log(boxen(noProjectMessage));
}
let command = `docker-compose logs${!ci ? ' -f' : ''}`;
shell.exec(command);
}
export async function getRunningDockerResources() {
const docker = new Docker({});
const command = 'container ps --format={{.Names}}';
const containersCommand = await docker.command(command);
const containers = containersCommand.raw.match(/(botfront-\w+)/g);
const networkCommand = await docker.command('network ls --format={{.Name}}');
const networks = networkCommand.raw.match(/([^\s]+_botfront-network)/g);
const volumeCommand = await docker.command('volume ls --format={{.Name}}');
const volumes = volumeCommand.raw.match(/([^\s]+_kchatbotbak-db)/g);
return { containers, networks, volumes };
}
export async function stopRunningProjects(
runningMessage = null,
killedMessage = null,
allDeadMessage = null,
spinner,
) {
const docker = new Docker({});
try {
const { containers, networks, volumes } = await getRunningDockerResources();
if (containers && containers.length) {
startSpinner(spinner, runningMessage);
await docker.command(`stop ${containers.join(' ')}`);
await docker.command(`rm ${containers.join(' ')}`);
if (killedMessage) succeedSpinner(spinner, killedMessage);
} else {
if (allDeadMessage) succeedSpinner(spinner, allDeadMessage);
}
if (volumes && volumes.length)
await docker.command(`volume rm ${volumes.join(' ')}`);
if (networks && networks.length)
await docker.command(`network rm ${networks.join(' ')}`);
stopSpinner();
} catch (e) {
stopSpinner();
const failMessage =
`Could not stop running project. Run ${chalk.cyan.bold(
'docker ps | grep -w botfront-',
)} and ` +
(`then ${chalk.cyan.bold('docker stop <name>')} and ${chalk.cyan.bold(
'docker rm <name>',
)} for ` +
`each running container.\n${chalk.red.bold(e)}\n`);
failSpinner(spinner, failMessage);
}
}
export async function watchFolder({ verbose }, workingDir) {
const spinner = ora();
const actionsPath = path.join(fixDir(), 'actions');
const rasaPath = path.join(fixDir(), 'rasa');
const actionsService = 'actions';
const watchingMessage = 'Watching for file system changes...';
const rasaService = 'rasa';
watch(actionsPath, {
ignored: /(^|[/\\])\..|pyc|ignore/,
ignoreInitial: true,
interval: 1000,
}).on('all', async () => {
stopSpinner(spinner);
await dockerComposeBuildAndRestart(actionsService, { verbose }, workingDir);
startSpinner(spinner, watchingMessage);
});
watch(rasaPath, {
ignored: /(^|[/\\])\..|pyc|ignore/,
ignoreInitial: true,
interval: 1000,
}).on('all', async () => {
stopSpinner(spinner);
await dockerComposeBuildAndRestart(rasaService, { verbose }, workingDir);
startSpinner(spinner, watchingMessage);
});
startSpinner(spinner, 'Watching for file system changes...');
}