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:
- The input
spike-cli init [projectName]
The command - The user enters the project name (e.g
projectName
Skip if it is legal) - The user enters the project version number
- The user selects the project template
- Download the template
- Installation template
- 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.