preface

Daily work often needs to build project structure. If the built project is encapsulated as a template, the project can be initialized with a simple command when it needs to be built again. Can quickly build the basic structure of the project and provide project specifications and conventions, greatly improve team efficiency.

Design scaffolding

Prior to development, we needed to design our own scaffolding process based on requirements. The author’s requirement is to build Vue and React projects frequently. Therefore, the core design process is as follows:

  1. The inputspike-cli init [projectName]The command
  2. The user enters the project name (e.gprojectNameSkip if it is legal)
  3. The user enters the project version number
  4. The user selects the project template
  5. Download the template
  6. Installation template
  7. Start the project

According to the core process, we added some pre-validation: check node version, check whether root is started, check user home directory, check whether update is needed.

The author’s habit is to sort out the process and draw it into a flow chart. Later in development, writing code that follows the logic of the flowchart keeps your mind clear. Scaffolding flow chart is as follows:

Scaffolding prototype

All we need to do now is start our scaffolding by typing spike-cli init [projectName].

1. New entry file SRC/core/cli/bin/index, js
#! /usr/bin/env node
require('.. /lib')(process.argv.slice(2))
Copy the code

The role of this code is the use of the node to perform the SRC/core/cli/lib/index. The js file content. Our scaffolding code in the SRC/core/cli/lib/index, js, later described in detail the content inside.

2. Add the bin attribute to the package.json file
{
  "name": "spike-cli"."version": "1.0.0"."bin": {
    "spike-cli": "src/core/cli/bin/index.js"},... }Copy the code

When user NPM install spike-cli -g, In/usr/local/bin directory to generate a spike – cli command – > / usr/local/lib/node_modules/spike – cli/SRC/core/cli/bin/index, js. This allows the user to execute our file when using spike- CLI directly.

3.npm link

We debug scaffolding for convenience during development. You can either enter NPM link or link the bin property in package.json to /usr/local/bin. If you do not need it, you can use NPM unlink to cancel the link.

The preparation stage of the handstand

From there, we write the concrete logic of the scaffolding. First, check the node version, check whether root is started, check the user home directory, and check whether updates are needed.

1. Newsrc/core/cli/lib/index.js
async function core() {
  try {
    await prepare();
  } catch(e) { log.error(e.message); }}// Preparation phase
async function prepare() {
  checkRoot();
  checkUserHome();
  await checkGlobalUpdate();
}

// Check the node version
function checkNodeVersion() {
    const currentVersion = process.version;
    if (semver.lt(currentVersion, LOWEST_NODE_VERSION)) {
      log.error(colors.red('spike- CLI needs to install V${LOWEST_NODE_VERSION}Previous versions of Node.js));
      process.exit(1); }}// Check whether the root account is started
function checkRoot() {
  const rootCheck = require('root-check');
  rootCheck();
}

// Check whether the home directory is used
function checkUserHome() {
  if(! userHome || ! pathExists(userHome)) {throw new Error(colors.red('Current login user home directory does not exist! '))}}// Check whether the version needs to be updated
async function checkGlobalUpdate() {
  const currentVersion = pkg.version;
  const npmName = pkg.name;
  const lastVersion = await getNpmSemverVersion(currentVersion,npmName);
  if(lastVersion) {
    log.warn('Update Prompt', colors.yellow('Please update manually${npmName}, current version:${currentVersion}, latest version:${lastVersion}\n Update command: NPM install -g${npmName}`))}}module.exports = core;
Copy the code

The above code does some checking. There will be many third-party packages in it and the next. Let’s first understand their functions:

The name of the Introduction to the
npmlog Custom level and color log output
colors Custom log output color and style
user-home Get the user home directory
root-check Starting the root account
semver Project version-related operations
fs-extra System FS module extension
commander Command line custom command
inquire The cli asks the user questions and records the answers

Scaffolding command registration

With all the above preparations cleared, we started registering the command. Here we use commander to help us register an init [projectName] command and two options :force and option:debug. This allows us to interact using the following names

spike-cli init testProject --debug --force
Copy the code

–debug and –force are not transmitted. — Debug enables the debug mode. The –force parameter forcibly initializes the project.

Let’s look at the command registration code:

async function core() {
  try {
    await prepare();
+   registerCommand();
  } catch(e) { log.error(e.message); }}// Register the command command
function registerCommand() {
  program
    .name(Object.keys(pkg.bin)[0])
    .usage('<command> [options]')
    .version(pkg.version)
    .option('-d, --debug'.'Debug mode enabled'.false)

  // Register init command
  program
    .command('init [projectName]')
    .option('-f --force'.'Force initialization of the project')
    .action(init)

  // Listen on degub option
  program.on('option:debug'.function() {
    if (program._optionValues.debug) {
      process.env.LOG_LEVEL = 'verbose';
    }
    log.level = process.env.LOG_LEVEL;
    log.verbose('Enable Debug mode')})// Listen for an unknown command
  program.on('command:*'.function (obj) {
    const availableCommands = program.commands.map(cmd= > cmd.name());
    console.log(colors.red('Unknown command:' + obj[0]));
    if (availableCommands.length > 0) {
      console.log(colors.green('Available commands:' + availableCommands.join(', ')));
    }
    if (program.args && program.args.length < 1) {
      program.outputHelp();
    }
  })

  program.parse(process.argv);
}
Copy the code

Scaffolding command execution

When we listen to init [projectName], we execute the init function. Let’s look at the code for the init function:

function init() {
  const argv = Array.from(arguments);
  const cmd = argv[argv.length - 1];
  const o = Object.create(null);
  Object.keys(cmd).forEach(key= > {
    if(key === '_optionValues') {
      o[key] = cmd[key];
    }
  })
  argv[argv.length - 1] = o;
  return new InitCommand(argv);
}
Copy the code

The init function takes command arguments, which are so verbose that only the useful optionValues arguments are left. Execute InitCommand and pass in the simplified arguments.

Let’s look at the internal code of InitCommand:

class InitCommand {
  constructor(argv) {
    if (!Array.isArray(argv)) {
      throw new Error('Parameter must be array! ');
    }
    if(! argv || argv.length <1) {
      throw new Error('Parameter cannot be empty! ');
    }
    this._argv = argv;
    new Promise((resolve, reject) = > {
      let chain = Promise.resolve();
      chain = chain.then(() = > this.initArgs());
      chain = chain.then(() = > this.exec());
      chain.catch(e= >log.error(e.message)); })}initArgs() {
    this._cmd = this._argv[this._argv.length - 1];
    this._argv = this._argv.slice(0.this._argv.length - 1);
    this.projectName = this._argv[0] | |' ';
    this.force = !!this._cmd._optionValues.force;
  }
  
  async exec() {
    try {
      // 1. Interaction phase
      const projectInfo = await this.interaction();
      this.projectInfo = projectInfo;
      if (projectInfo) {
        log.verbose('projectInfo', projectInfo);
       // 2. Download the template
        await this.downloadTemplate();
        // 3. Install the template
        await this.installTemplate(); }}catch(e) { log.error(e.message); }}}Copy the code

This code is the core flow of command execution. Firstly, verify the parameters and integrate the parameters, and then enter the interactive stage, download template stage, installation template stage.

Interaction stage

When entering the interactive phase, we first need to determine whether the current directory is empty. If it is empty, whether to start forced update, and then confirm whether to clear the current directory again. Here we need inquire to help us solve the command line interaction problem. Let’s look at the code below:

async interaction() {
  // 1. Check whether the current directory is empty
  const localPath = process.cwd();
  if (!this.isDirEmpty(localPath)) {
    // 2. Determine whether to enable forcible update
    let isContinue = false;
    if(!this.force) {
      isContinue = (await inquirer.prompt({
        type: 'confirm'.name: 'isContinue'.message: 'The current folder is not empty. Do you want to continue creating the project? '.default: false
      })).isContinue;

      if(! isContinue)return;
    }

    if (isContinue || this.force) {
      // 3. Check whether the current directory is cleared
      const { confirmDelete } = await inquirer.prompt({
        type: 'confirm'.name: 'confirmDelete'.default: false.message: 'Are you sure you want to clear the files in the current directory? '
      })
      if(confirmDelete) { fse.emptyDirSync(localPath); }}}return this.getProjectInfo();
}
Copy the code

If the user continues to select create, we execute the getProjectInfo function. It asks the user for the project name, project version, and selected template, and returns project information.

We need to upload the template to NPM in advance and have the template information ready.

const template = [
  {
    name: React Standard template.npmName: 'spike-cli-template-react'.version: '1.0.0'.installCommand: 'npm install'.startCommand: 'npm run start'
  },
  {
    name: 'the react + redux template'.npmName: 'spike-cli-template-react-redux'.version: '1.0.0'.installCommand: 'npm install'.startCommand: 'npm run start'
  },
  {
    name: 'VuE3 Standard Template'.npmName: 'spike-cli-template-vue3'.version: '1.0.0'.installCommand: 'npm install'.startCommand: 'npm run serve'}]module.exports = template;
Copy the code

Let’s look at the code:

async getProjectInfo() {
  function isValidName(v) {
    return /^[a-zA-Z]+([-][a-zA-Z][a-zA-Z0-0]*|[_][a-zA-Z][a-zA-Z0-0]*|[a-zA-Z0-9]*)$/.test(v);
  }
  console.log('getProjectInfo');
  let projectInfo = {};
  const projectPrompts = [];
  let isProjectNameValid = false;
  if (isValidName(this.projectName)) {
    isProjectNameValid = true;
    projectInfo.projectName = this.projectName;
  }

  if(! isProjectNameValid) { projectPrompts.push({type: 'input'.name: 'projectName'.message: 'Please enter project name'.default: ' '.validate: function (v) {
        const done = this.async();
        setTimeout(() = > {
          // 1. The first letter must be an English letter
          // 2. The last letter must be an English or a number, not a character
          // 3. Only "-_" characters are allowed.
          // Valid: a, A-b, a_b, A-b-C, a_b_c, A-b1-C1, a_b1_C1
          // Invalid: 1, a_, a-, a_1, a-1
          if(! isValidName(v)) { done('Please enter a valid project name');
            return;
          }
          done(null.true);
        }, 0);
      },
      filter: function (v) {
        return v;
      }
    })
  }

  projectPrompts.push({
    type: 'input'.name: 'projectVersion'.message: 'Please enter the project version number'.default: '1.0.0'.validate: function (v) {
      const done = this.async();
      setTimeout(() = > {
        if(! semver.valid(v)) { done('Please enter a valid version number');
          return;
        }
        done(null.true);
      }, 0);
    },
    filter: function (v) {
      if(!!!!! semver.valid(v)) {return semver.valid(v);
      } else {
        return v;
      }

    }
  })

  projectPrompts.push({
    type: 'list'.name: 'projectTemplate'.message: 'Please select project template'.choices: this.createTemplateChoices()
  })

  const project = awaitinquirer.prompt(projectPrompts); projectInfo = { ... projectInfo, ... project }if (projectInfo.projectName) {
    projectInfo.name = projectInfo.projectName;
    projectInfo.version = projectInfo.projectVersion;
    projectInfo.className = require('kebab-case')(projectInfo.projectName);
  }

  return projectInfo
}
Copy the code

Download the template

After interacting with the user, we started downloading the selected project template. Here we have the npmInstall to help us download it. Let’s look at the code below:

async downloadTemplate() {
  const { projectTemplate } = this.projectInfo;
  this.templateInfo = template.find(item= > item.npmName === projectTemplate);
  const spinner = spinnerStart('Downloading templates... ');
  await sleep();
  try {
    await npmInstall({
      root: process.cwd(),
      registry: getDefaultRegistry(),
      pkgs: [{ name: this.templateInfo.npmName, version: this.templateInfo.version }]
    })
  } catch (e) {
    throw new Error(e);
  }finally {
    spinner.stop(true);
    this.templatePath = path.resolve(process.cwd(), 'node_modules'.this.templateInfo.npmName, 'template');
    if (pathExists(this.templatePath)) {
      log.success('Template download successful! '); }}}Copy the code

The template here will be downloaded to node_modules, and we will concatenate the template path in advance to prepare for copying the template to the current directory.

Installation template

We first copy the template code to the current directory. Then use EJS to render packageName and version to package.json. Then run the installation command and start command.

Let’s look at the code below:

async installTemplate() {
  let spinner = spinnerStart('Installing templates... ');
  await sleep();
  try {
    // Copy the template code to the current directory
    fse.copySync(this.templatePath, process.cwd());
  } catch (e) {
    throw new Error(e);
  }finally {
    spinner.stop(true);
    log.success('Template installed successfully! ');
  }

  const options = {
    ignore: ['node_modules/**'.'public/**']}await this.ejsRender(options);

  const { installCommand, startCommand } = this.templateInfo;
  // Install command
  await this.exexCommand(installCommand, 'Dependency failed during installation! ');
  // Start the command
  await this.exexCommand(startCommand, 'Dependency failed during installation! ')}async ejsRender(options) {
  const dir = process.cwd();
  return new Promise((resolve, reject) = > {
    glob('* *', {
      cwd: dir,
      ignore: options.ignore || ' '.nodir: true
    }, (err, files) = > {
      if (err) {
        reject(err);
      }
      Promise.all(files.map(file= > {
        const filePath = path.join(dir, file);
        return new Promise((resolve1, reject1) = > {
          ejs.renderFile(filePath, this.projectInfo, {}, (err, result) = > {
            if (err) {
              reject1(err);
            } else {          
              fse.writeFileSync(filePath, result);
              resolve1(result);
            }
          })
        })
      }))
        .then(() = > resolve())
        .catch(err= > reject(err))
    })
  })
}
Copy the code

The code looks a little bit more here, but the core logic is spelled out above. The exexCommand function is the only one not covered in detail. It is used to execute incoming Node commands, using the spawn method in node’s built-in child_process module.

Let’s look at the code:

async exexCommand(command, errMsg) {
  let ret;
  if(command) {
    const cmdArray = command.split(' ');
    const cmd = this.checkCommand(cmdArray[0]);
    if(! cmd) {throw new Error('Command does not exist! Command: ' + cmd);
    }
    const args = cmdArray.slice(1);
    ret = await execAsync(cmd, args, { stdio: 'inherit'.cwd: process.cwd() });
  }
  if(ret ! = =0) {
    throw new Error(errMsg)
  }
  return ret;
}

function execAsync(command, args, options) {
  return new Promise((resolve, reject) = > {
    const p = exec(command, args, options);
    p.on('error'.e= > {
      reject(e);
    })
    p.on('exit'.c= > {
      resolve(c)
    })
  })
}

function exec(command, args, options) {
  const win32 = process.platform === 'win32';
  const cmd = win32 ? 'cmd' : command;
  const cmdArgs = win32 ? ['/c'].concat(command, args) : args;
  return require('child_process').spawn(cmd, cmdArgs, options || {});
}
Copy the code

At this point, our scaffolding is developed and ready to publish to NPM.

conclusion

I have published spike- CLI on NPM. Interested readers can try it out with the following command:

npm install spike-cli -g
spike-cli init testProject
Copy the code

Spike – CLI scaffolding is designed by the author according to his own needs, and readers can also design corresponding links according to their own needs.

Hopefully this article will help readers build their own scaffolding and improve development efficiency.