Skip to content

Commit

Permalink
feat: add forest init command (#58)
Browse files Browse the repository at this point in the history
  • Loading branch information
larcin authored May 27, 2020
1 parent d73856f commit 872b130
Show file tree
Hide file tree
Showing 21 changed files with 1,217 additions and 8 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"bluebird": "3.5.2",
"chalk": "4.0.0",
"cli-table": "github:Automattic/cli-table#master",
"clipboardy": "2.3.0",
"dotenv": "8.2.0",
"inquirer": "6.2.0",
"joi": "14.3.1",
Expand Down
152 changes: 152 additions & 0 deletions src/commands/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
const fs = require('fs');
const { flags } = require('@oclif/command');
const inquirer = require('inquirer');
const envConfig = require('../config');
const AbstractAuthenticatedCommand = require('../abstract-authenticated-command');
const { buildDatabaseUrl } = require('../utils/database-url');
const withCurrentProject = require('../services/with-current-project');
const singletonGetter = require('../services/singleton-getter');
const Spinner = require('../services/spinner');
const logger = require('../services/logger');
const ProjectManager = require('../services/project-manager');
const EnvironmentManager = require('../services/environment-manager');
const {
handleInitError,
handleDatabaseConfiguration,
validateEndpoint,
getApplicationPortFromCompleteEndpoint,
amendDotenvFile,
createDotenvFile,
displayEnvironmentVariablesAndCopyToClipboard,
} = require('../services/init-manager');

const SUCCESS_MESSAGE_ALL_SET_AND_READY = "You're now set up and ready to develop on Forest Admin";
const SUCCESS_MESSAGE_LEARN_MORE_ON_CLI_USAGE = 'To learn more about the recommended usage of this CLI, please visit https://docs.forestadmin.com/getting-started/a-page-on-forest-cli.';

const PROMPT_MESSAGE_AUTO_FILLING_ENV_FILE = 'Do you want your current folder `.env` file to be completed automatically with your environment variables?';
const PROMPT_MESSAGE_AUTO_CREATING_ENV_FILE = 'Do you want a new `.env` file (containing your environment variables) to be automatically created in your current folder?';

const spinner = singletonGetter(Spinner);

class InitCommand extends AbstractAuthenticatedCommand {
constructor(...args) {
super(...args);
this.environmentVariables = {};
}

async runIfAuthenticated() {
try {
spinner.start({ text: 'Selecting your project' });
await spinner.attachToPromise(this.projectSelection());

spinner.start({ text: 'Analyzing your setup' });
await spinner.attachToPromise(this.projectValidation());

spinner.start({ text: 'Checking your database setup' });
await spinner.attachToPromise(this.handleDatabaseUrlConfiguration());

spinner.start({ text: 'Setting up your development environment' });
await spinner.attachToPromise(this.developmentEnvironmentCreation());

await this.environmentVariablesAutoFilling();

spinner.start({ text: SUCCESS_MESSAGE_ALL_SET_AND_READY });
spinner.success();
logger.info(SUCCESS_MESSAGE_LEARN_MORE_ON_CLI_USAGE);
} catch (error) {
const exitMessage = handleInitError(error);
this.error(exitMessage, { exit: 1 });
}
}

async projectSelection() {
const parsed = this.parse(InitCommand);
this.config = await withCurrentProject({ ...envConfig, ...parsed.flags });
}

async projectValidation() {
const project = await new ProjectManager(this.config).getProjectForDevWorkflow();
this.environmentVariables.projectOrigin = project.origin;
}

async handleDatabaseUrlConfiguration() {
if (this.environmentVariables.projectOrigin !== 'In-app') {
const isDatabaseAlreadyConfigured = !!process.env.DATABASE_URL;

if (!isDatabaseAlreadyConfigured) {
spinner.pause();
const databaseConfiguration = await handleDatabaseConfiguration();
spinner.continue();
if (databaseConfiguration) {
this.environmentVariables.databaseUrl = buildDatabaseUrl(databaseConfiguration);
this.environmentVariables.databaseSchema = databaseConfiguration.dbSchema;
this.environmentVariables.databaseSSL = databaseConfiguration.ssl;
}
}
}
}

async developmentEnvironmentCreation() {
let developmentEnvironment;
try {
developmentEnvironment = await new ProjectManager(this.config)
.getDevelopmentEnvironmentForUser(this.config.projectId);
} catch (error) {
developmentEnvironment = null;
}

if (!developmentEnvironment) {
spinner.pause();
const prompter = await inquirer.prompt([{
name: 'endpoint',
message: 'Enter your local admin backend endpoint:',
type: 'input',
default: 'http://localhost:3310',
validate: validateEndpoint,
}]);
spinner.continue();

developmentEnvironment = await new EnvironmentManager(this.config)
.createDevelopmentEnvironment(this.config.projectId, prompter.endpoint);
}
this.environmentVariables.forestEnvSecret = developmentEnvironment.secretKey;
this.environmentVariables.applicationPort = getApplicationPortFromCompleteEndpoint(
developmentEnvironment.apiEndpoint,
);
}

async environmentVariablesAutoFilling() {
if (this.environmentVariables.projectOrigin !== 'In-app') {
const existingEnvFile = fs.existsSync('.env');
const response = await inquirer
.prompt([{
type: 'confirm',
name: 'autoFillOrCreationConfirmation',
message: existingEnvFile
? PROMPT_MESSAGE_AUTO_FILLING_ENV_FILE
: PROMPT_MESSAGE_AUTO_CREATING_ENV_FILE,
}]);
if (response.autoFillOrCreationConfirmation) {
try {
return existingEnvFile
? amendDotenvFile(this.environmentVariables)
: createDotenvFile(this.environmentVariables);
} catch (error) {
return displayEnvironmentVariablesAndCopyToClipboard(this.environmentVariables);
}
}
}
return displayEnvironmentVariablesAndCopyToClipboard(this.environmentVariables);
}
}

InitCommand.description = 'Set up your development environment in your current folder.';

InitCommand.flags = {
projectId: flags.string({
char: 'p',
description: 'The id of the project you want to init.',
}),
};

module.exports = InitCommand;
1 change: 1 addition & 0 deletions src/serializers/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module.exports = new JSONAPISerializer('environments', {
attributes: [
'name',
'defaultEnvironment',
'origin',
],
defaultEnvironment: {
ref: 'id',
Expand Down
10 changes: 10 additions & 0 deletions src/services/environment-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ function EnvironmentManager(config) {
.then((response) => environmentDeserializer.deserialize(response.body));
};

this.createDevelopmentEnvironment = async (projectId, endpoint) => {
const authToken = authenticator.getAuthToken();

return agent
.post(`${serverHost()}/api/projects/${projectId}/development-environment-for-user`)
.set('Authorization', `Bearer ${authToken}`)
.send({ endpoint })
.then((response) => environmentDeserializer.deserialize(response.body));
};

this.updateEnvironment = async () => {
const authToken = authenticator.getAuthToken();
return agent
Expand Down
177 changes: 177 additions & 0 deletions src/services/init-manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
const inquirer = require('inquirer');
const chalk = require('chalk');
const clipboardy = require('clipboardy');
const fs = require('fs');
const logger = require('../services/logger');
const { handleError } = require('../utils/error');
const { generateKey } = require('../utils/key-generator');
const DatabasePrompter = require('../services/prompter/database-prompter');
const envConfig = require('../config');
const singletonGetter = require('../services/singleton-getter');
const Spinner = require('../services/spinner');


const SUCCESS_MESSAGE_ENV_VARIABLES_COPIED_IN_ENV_FILE = 'Copying the environment variables in your `.env` file';
const SUCCESS_MESSAGE_ENV_FILE_CREATED_AND_FILLED = 'Creating a new `.env` file containing your environment variables';
const SUCCESS_MESSAGE_DISPLAY_ENV_VARIABLES = 'Here are the environment variables you need to copy in your configuration file:\n';
const SUCCESS_MESSAGE_ENV_VARIABLES_COPIED_TO_CLIPBOARD = 'Automatically copied to your clipboard!';

const ERROR_MESSAGE_PROJECT_IN_V1 = 'This project does not support branches yet. Please migrate your environments from your Project settings first.';
const ERROR_MESSAGE_NOT_ADMIN_USER = "You need the 'Admin' role to create a development environment on this project.";
const ERROR_MESSAGE_PROJECT_BY_ENV_NOT_FOUND = 'Your project was not found. Please check your environment secret.';
const ERROR_MESSAGE_PROJECT_BY_OPTION_NOT_FOUND = 'The project you specified does not exist.';
const ERROR_MESSAGE_NO_PRODUCTION_OR_REMOTE_ENVIRONMENT = 'You cannot create your development environment until this project has either a remote or a production environment.';
const ERROR_MESSAGE_ENVIRONMENT_OWNER_UNICITY = 'You already have a development environment on this project.';

const VALIDATION_REGEX_URL = /^https?:\/\/.*/i;
const VALIDATION_REGEX_HTTPS = /^http((s:\/\/.*)|(s?:\/\/(localhost|127\.0\.0\.1).*))/i;
const SPLIT_URL_REGEX = new RegExp('(\\w+)://([\\w\\-\\.]+)(:(\\d+))?');

const ENV_VARIABLES_AUTO_FILLING_PREFIX = '\n\n# ℹ️ The content below was automatically added by the `forest init` command ⤵️\n';

const spinner = singletonGetter(Spinner);

const OPTIONS_DATABASE = [
'dbDialect',
'dbName',
'dbHostname',
'dbPort',
'dbUser',
'dbPassword',
'dbSchema',
'ssl',
'mongodbSrv',
];

function handleInitError(rawError) {
const error = handleError(rawError);
switch (error) {
case 'Dev Workflow disabled.':
return ERROR_MESSAGE_PROJECT_IN_V1;
case 'Forbidden':
return ERROR_MESSAGE_NOT_ADMIN_USER;
case 'Project by env secret not found':
return ERROR_MESSAGE_PROJECT_BY_ENV_NOT_FOUND;
case 'Project not found':
return ERROR_MESSAGE_PROJECT_BY_OPTION_NOT_FOUND;
case 'No production/remote environment.':
return ERROR_MESSAGE_NO_PRODUCTION_OR_REMOTE_ENVIRONMENT;
case 'A user can have only one development environment per project.':
return ERROR_MESSAGE_ENVIRONMENT_OWNER_UNICITY;
case 'An environment with this name already exists. Please choose another name.':
return ERROR_MESSAGE_ENVIRONMENT_OWNER_UNICITY;
default:
return error;
}
}

async function handleDatabaseConfiguration() {
const response = await inquirer
.prompt([{
type: 'confirm',
name: 'confirm',
message: 'You don\'t have a DATABASE_URL yet. Do you need help setting it?',
}]);

if (!response.confirm) return null;

const promptContent = [];
await new DatabasePrompter(OPTIONS_DATABASE, envConfig, promptContent, { }).handlePrompts();
return inquirer.prompt(promptContent);
}

function validateEndpoint(input) {
if (!VALIDATION_REGEX_URL.test(input)) {
return 'Application input must be a valid url.';
}
if (!VALIDATION_REGEX_HTTPS.test(input)) {
return 'HTTPS protocol is mandatory, except for localhost and 127.0.0.1.';
}
return true;
}

function getApplicationPortFromCompleteEndpoint(endpoint) {
return endpoint.match(SPLIT_URL_REGEX)[4];
}

function getContentToAddInDotenvFile(environmentVariables) {
const authSecret = generateKey();
let contentToAddInDotenvFile = '';

if (environmentVariables.applicationPort) {
contentToAddInDotenvFile += `APPLICATION_PORT=${environmentVariables.applicationPort}\n`;
}
if (environmentVariables.databaseUrl) {
contentToAddInDotenvFile += `DATABASE_URL=${environmentVariables.databaseUrl}\n`;
}
if (environmentVariables.databaseSchema) {
contentToAddInDotenvFile += `DATABASE_SCHEMA=${environmentVariables.databaseSchema}\n`;
}
if (environmentVariables.databaseSSL !== undefined) {
contentToAddInDotenvFile += `DATABASE_SSL=${environmentVariables.databaseSSL}\n`;
}
contentToAddInDotenvFile += `FOREST_AUTH_SECRET=${authSecret}\n`;
contentToAddInDotenvFile += `FOREST_ENV_SECRET=${environmentVariables.forestEnvSecret}`;
return contentToAddInDotenvFile;
}

function commentExistingVariablesInAFile(fileData, environmentVariables) {
const variablesToComment = {
'FOREST_AUTH_SECRET=': '# FOREST_AUTH_SECRET=',
'FOREST_ENV_SECRET=': '# FOREST_ENV_SECRET=',
};
if (environmentVariables.applicationPort) {
variablesToComment['APPLICATION_PORT='] = '# APPLICATION_PORT=';
}
if (environmentVariables.databaseUrl) {
variablesToComment['DATABASE_URL='] = '# DATABASE_URL=';
variablesToComment['DATABASE_SCHEMA='] = '# DATABASE_SCHEMA=';
variablesToComment['DATABASE_SSL='] = '# DATABASE_SSL=';
}
const variablesToCommentRegex = new RegExp(
Object.keys(variablesToComment).map((key) => `((?<!# )${key})`).join('|'),
'g',
);
return fileData.replace(variablesToCommentRegex, (match) => variablesToComment[match]);
}

function amendDotenvFile(environmentVariables) {
let newEnvFileData = getContentToAddInDotenvFile(environmentVariables);
spinner.start({ text: SUCCESS_MESSAGE_ENV_VARIABLES_COPIED_IN_ENV_FILE });
const existingEnvFileData = fs.readFileSync('.env', 'utf8');
if (existingEnvFileData) {
const amendedExistingFileData = commentExistingVariablesInAFile(
existingEnvFileData,
environmentVariables,
);
// NOTICE: We add the prefix only if the existing file was not empty.
newEnvFileData = amendedExistingFileData + ENV_VARIABLES_AUTO_FILLING_PREFIX + newEnvFileData;
}
fs.writeFileSync('.env', newEnvFileData);
spinner.success();
}

function createDotenvFile(environmentVariables) {
const contentToAdd = getContentToAddInDotenvFile(environmentVariables);
spinner.start({ text: SUCCESS_MESSAGE_ENV_FILE_CREATED_AND_FILLED });
fs.writeFileSync('.env', contentToAdd);
spinner.success();
}

async function displayEnvironmentVariablesAndCopyToClipboard(environmentVariables) {
const variablesToDisplay = getContentToAddInDotenvFile(environmentVariables);
logger.info(SUCCESS_MESSAGE_DISPLAY_ENV_VARIABLES + chalk.black.bgCyan(variablesToDisplay));
await clipboardy.write(variablesToDisplay)
.then(() => logger.info(chalk.italic(SUCCESS_MESSAGE_ENV_VARIABLES_COPIED_TO_CLIPBOARD)))
.catch(() => null);
}

module.exports = {
handleInitError,
handleDatabaseConfiguration,
validateEndpoint,
getApplicationPortFromCompleteEndpoint,
amendDotenvFile,
createDotenvFile,
displayEnvironmentVariablesAndCopyToClipboard,
};
10 changes: 10 additions & 0 deletions src/services/project-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ function ProjectManager(config) {
.then((response) => deserialize(response));
};

this.getProjectForDevWorkflow = async () => {
const authToken = authenticator.getAuthToken();

return agent
.get(`${serverHost()}/api/projects/${config.projectId}/dev-workflow`)
.set('Authorization', `Bearer ${authToken}`)
.send()
.then((response) => deserialize(response));
};

this.getDevelopmentEnvironmentForUser = async (projectId) => {
const authToken = authenticator.getAuthToken();

Expand Down
13 changes: 13 additions & 0 deletions src/services/prompter/abstract-prompter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class AbstractPrompter {
constructor(requests) {
this.requests = requests;
}

isOptionRequested(option) {
if (!option) { return false; }

return this.requests.includes(option);
}
}

module.exports = AbstractPrompter;
Loading

0 comments on commit 872b130

Please sign in to comment.