Home Manual Reference Source

src/services/cli/generators/projectConfigurationFile.js

const ObjectUtils = require('wootils/shared/objectUtils');
const fs = require('fs-extra');
const { provider } = require('jimple');
const CLISubCommand = require('../../../abstracts/cliSubCommand');
/**
 * This is a CLI generator that allows the user to create a configuration file with all the
 * default settings and all the information projext assumes about the project.
 * @extends {CLISubCommand}
 */
class ProjectConfigurationFileGenerator extends CLISubCommand {
  /**
   * Class constructor.
   * @param {Logger}                       appLogger            To inform the user when the file
   *                                                            has been generated, or if something
   *                                                            went wrong.
   * @param {Prompt}                       appPrompt            To ask the user the path to the
   *                                                            file.
   * @param {PathUtils}                    pathUtils            To build the absolute path for the
   *                                                            file.
   * @param {ProjectConfigurationSettings} projectConfiguration To get all the settings that are
   *                                                            going to go on the file.
   * @param {Utils}                        utils                To format some of the options
   *                                                            into human readable descriptions.
   */
  constructor(appLogger, appPrompt, pathUtils, projectConfiguration, utils) {
    super();
    /**
     * A local reference for the `appLogger` service.
     * @type {Logger}
     */
    this.appLogger = appLogger;
    /**
     * A local reference for the `appPrompt` service.
     * @type {Prompt}
     */
    this.appPrompt = appPrompt;
    /**
     * A local reference for the `pathUtils` service.
     * @type {PathUtils}
     */
    this.pathUtils = pathUtils;
    /**
     * All the project settings.
     * @type {ProjectConfigurationSettings}
     */
    this.projectConfiguration = projectConfiguration;
    /**
     * A local reference for the `utils` service.
     * @type {Utils}
     */
    this.utils = utils;
    /**
     * The resource type the user will have to select on the CLI command that manages the
     * generator.
     * @type {string}
     */
    this.name = 'config';
    /**
     * A short description of what the generator does.
     * @type {string}
     */
    this.description = 'Generate a configuration based on what projext knows of your project';
    /**
     * A list with the names the configuration file can have and that projext supports.
     * @type {Array}
     * @ignore
     * @access protected
     */
    this._nameOptions = [
      'projext.config.js',
      'config/projext.config.js',
      'config/project.config.js',
    ];

    this.addOption(
      'all',
      '-a, --all',
      'Save the file with all the project settings instead of just the targets',
      false
    );
    this.addOption(
      'include',
      '-i, --include',
      'A list of directory-like paths of the specific settings you want to include. ' +
        'To use without -all',
      'targets'
    );
    this.addOption(
      'exclude',
      '-e, --exclude',
      'A list of directory-like paths of the specific settings you want to exclude. ' +
        'To use with -all',
      ''
    );
  }
  /**
   * This method first prompts the user for the name of configuration file, it needs to be one
   * supported by projext, after that, if the file already exists it asks for confirmation, and then
   * it finally writes it.
   * @param {Object}  options         A dictionary with the received options for the generator.
   * @param {boolean} options.all     Whether to save all the settings or just the targets.
   * @param {?string} options.include A list of directory-like paths for specific settings to save.
   * @param {?string} options.exclude A list of directory-like paths for specific settings to
   *                                  ignore.
   * @return {Promise<undefined,Error>}
   */
  handle(options = {}) {
    // Define the variable for the promise that will be returned.
    let result;
    // Define the variable for the object that will contain the settings to write.
    let settings;

    // Validate that the required settings exist.
    try {
      settings = options.all ?
        this._getAllSettings(options.exclude) :
        this._getSettings(options.include);
    } catch (error) {
      result = Promise.reject(error);
    }

    // Continue with the execution only if the wasn't an error obtaining the settings.
    if (!result) {
      // Get the first name option to use as default.
      const [firstNameOption] = this._nameOptions;
      /**
       * Format the list so it can be added as an error message in case the user selects an
       * invalid name.
       */
      const nameOptionsStr = this.utils.humanReadableList(
        this._nameOptions.map((option) => `'${option}'`)
      );
      // Define the prompt schema.
      const schema = {
        filename: {
          default: firstNameOption,
          description: 'Filename',
          message: `It can only be one of these: ${nameOptionsStr}`,
          required: true,
          // Validate that the selected name is supported by projext.
          conform: (value) => this._nameOptions.includes(value.toLowerCase()),
          // Always save the selected name on lower case.
          before: (value) => value.toLowerCase(),
        },
        overwrite: {
          type: 'boolean',
          default: 'yes',
          description: 'Overwrite existing file',
          required: true,
          // Only ask for an overwrite confirmation if the file already exists.
          ask: () => {
            const filename = this.appPrompt.getValue('filename');
            return fs.pathExistsSync(this.pathUtils.join(filename));
          },
        },
      };

      let filepath;
      let creating = false;
      // Ask the user...
      return this.appPrompt.ask(schema)
      .then((results) => {
        // Build the path to the file.
        filepath = this.pathUtils.join(results.filename);
        // Check if the file already exists.
        const exists = fs.pathExistsSync(filepath);
        let nextStep;
        // If the file doesn't exist or if it exists but the user choose to overwrite it...
        if (!exists || (exists && results.overwrite)) {
          creating = true;
          // ...write the file.
          nextStep = this._writeSettings(filepath, settings);
        }

        return nextStep;
      })
      .then(() => {
        // If the file was created, inform the user.
        if (creating) {
          this.appLogger.success(`The configuration file was successfully generted: ${filepath}`);
        }
      })
      .catch((error) => {
        let nextStep;
        // If the process failed and it wasn't because the user canceled the input...
        if (error.message !== 'canceled') {
          // ...show the error.
          this.appLogger.error('There was an error while generating the configuration file');
          nextStep = Promise.reject(error);
        }

        return nextStep;
      });
    }

    return result;
  }
  /**
   * Get all the settings on the project configuration, with the possibility of excluding some
   * of them.
   * @param {string} [exclude=''] A list of comma separated paths for settings that should be
   *                              excluded.
   *                              For example: `'targetsTemplates/browser,copy,version'`.
   * @return {Object}
   * @ignore
   * @access protected
   */
  _getAllSettings(exclude = '') {
    return exclude
    .split(',')
    .reduce(
      (obj, objPath) => (objPath ? ObjectUtils.delete(obj, objPath, '/', true, true) : obj),
      ObjectUtils.copy(this.projectConfiguration)
    );
  }
  /**
   * Get specific settings from the project configuration.
   * @param {string} [settings='targets'] A list of comma separated paths for the required settings
   *                                      For example: `'targetsTemplates/browser,copy,version'`.
   * @return {Object}
   * @ignore
   * @access protected
   */
  _getSettings(settings = 'targets') {
    return settings
    .split(',')
    .reduce(
      (obj, objPath) => {
        const value = ObjectUtils.get(this.projectConfiguration, objPath, '/', true);
        return ObjectUtils.set(obj, objPath, value, '/', true);
      },
      {}
    );
  }
  /**
   * Formats a settings dictionary in order to write it as a JS object on an specific file.
   * @param {string} filepath The path to the file where the configuration should be written.
   * @param {Object} settings The dictionary of settings to write on the file.
   * @return {Promise<undefined,Error>}
   * @private
   * @access protected
   */
  _writeSettings(filepath, settings) {
    // Convert the configuration into a string with proper indentation.
    const jsonIndentation = 2;
    const json = JSON.stringify(settings, undefined, jsonIndentation)
    // Escape single quotes.
    .replace(/'/g, '\\\'')
    // Replace double quotes with single quotes.
    .replace(/"/g, '\'')
    // Remove single quotes from keys.
    .replace(/^(\s+)?(')(\w+)('): /mg, '$1$3: ')
    /**
     * Add trailing commas. The reason the regex is executed twice is because matches can't
     * intersect other matches, and since the regex uses a closing symbol as delimiter, that same
     * delimiter can't be fixed unless we run the regex again.
     */
    .replace(/([\]|}|\w|'])(\n(?:\s+)?[}|\]])/g, '$1,$2')
    .replace(/([\]|}])(\n(?:\s+)?[}|\]])/g, '$1,$2');

    const template = `module.exports = ${json};\n`;

    return fs.writeFile(filepath, template);
  }
}
/**
 * The service provider that once registered on the app container will set an instance of
 * `ProjectConfigurationFileGenerator` as the `projectConfigurationFileGenerator` service.
 * @example
 * // Register it on the container
 * container.register(projectConfigurationFileGenerator);
 * // Getting access to the service instance
 * const projectConfigurationFileGenerator = container.get('projectConfigurationFileGenerator');
 * @type {Provider}
 */
const projectConfigurationFileGenerator = provider((app) => {
  app.set('projectConfigurationFileGenerator', () => new ProjectConfigurationFileGenerator(
    app.get('appLogger'),
    app.get('appPrompt'),
    app.get('pathUtils'),
    app.get('projectConfiguration').getConfig(),
    app.get('utils')
  ));
});

module.exports = {
  ProjectConfigurationFileGenerator,
  projectConfigurationFileGenerator,
};