npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

spike-cli

v1.0.9

Published

spike-cli

Downloads

14

Readme

从零构建自己的脚手架spike-cli

前言

日常工作中经常需要搭建项目结构。如果将搭建好的项目封装为模板,再次需要搭建时,通过简单的命令即可完成项目初始化。便可快速搭建项目的基本结构并提供项目规范和约定,极大提高团队效率。

设计脚手架

在开发之前,我们需要根据需求设计出自己的脚手架流程。笔者的需求是,要经常搭建Vue、React项目。所以设计方案核心流程为:

  1. 输入spike-cli init [projectName]命令
  2. 用户输入项目名称(如输入projectName合法则跳过)
  3. 用户输入项目版本号
  4. 用户选择项目模板
  5. 下载模板
  6. 安装模板
  7. 启动项目

我们根据核心流程,再增加一些前置校验:检查node版本检查是否root启动检查用户主目录检查是否需要更新

笔者的习惯是将流程梳理清楚之后,绘制成流程图。之后开发时,按照流程图上的逻辑编写代码可以保持思路清晰。脚手架流程图如下:spike-cli流程图

脚手架雏形

我们现在需要做的就是输入spike-cli init [projectName]命令之后,启动我们的脚手架。

1.新建入口文件src/core/cli/bin/index.js
#! /usr/bin/env node
require('../lib')(process.argv.slice(2))

这段代码的作用是使用node来执行src/core/cli/lib/index.js文件的内容。我们的脚手架代码会放在src/core/cli/lib/index.js中,后面会详细介绍里面的内容。

2.package.json文件添加bin属性
{
  "name": "spike-cli",
  "version": "1.0.0",
  "bin": {
    "spike-cli": "src/core/cli/bin/index.js"
  },
  ...
}

当用户npm install spike-cli -g时,就会在/usr/local/bin目录下生成一个spike-cli的命令 --> /usr/local/lib/node_modules/spike-cli/src/core/cli/bin/index.js。这样当用户直接使用spike-cli时,就可以执行我们的文件。

3.npm link

我们在开发时,为了方便调试脚手架。可以输入npm link,也可以将package.jsonbin属性,链接到/usr/local/bin目录下。当不需要时,可以通过npm unlink,取消链接即可。

架手架准备阶段

从这开始,我们编写架手架的具体逻辑。首先我们来做前置校验:检查node版本检查是否root启动检查用户主目录检查是否需要更新

1.新建src/core/cli/lib/index.js
async function core() {
  try {
    await prepare();
  } catch (e) {
    log.error(e.message);
  }
}

// 准备阶段
async function prepare() {
  checkRoot();
  checkUserHome();
  await checkGlobalUpdate();
}

// 检查node版本
function checkNodeVersion() {
    const currentVersion = process.version;
    if (semver.lt(currentVersion, LOWEST_NODE_VERSION)) {
      log.error(colors.red(`spike-cli 需要安装 v${LOWEST_NODE_VERSION} 以上版本的 Node.js`));
      process.exit(1);
    }
}

// 检查是否是否root账户启动
function checkRoot() {
  const rootCheck = require('root-check');
  rootCheck();
}

// 检查是否用户主目录
function checkUserHome() {
  if (!userHome || !pathExists(userHome)) {
    throw new Error(colors.red('当前登录用户主目录不存在!'))
  }
}

// 检查是否需要更新版本
async function checkGlobalUpdate() {
  const currentVersion = pkg.version;
  const npmName = pkg.name;
  const lastVersion = await getNpmSemverVersion(currentVersion,npmName);
  if(lastVersion) {
    log.warn('更新提示', colors.yellow(`请手动更新 ${npmName}, 当前版本:${currentVersion}, 最新版本:${lastVersion}\n更新命令:npm install -g ${npmName}`))
  }
}

module.exports = core;

上面的代码的作用就是做一些检查功能。其中和接下来会用很多第三方package,我们先来了解一下它们的作用:

| 名称 | 简介 | | :----------------------------------------------------- | -------------------------------- | | npmlog | 自定义级别和彩色log输出 | | colors | 自定义log输出颜色和样式 | | user-home | 获得用户主目录 | | root-check | root账户启动 | | semver | 项目版本相关操作 | | fs-extra | 系统fs模块的扩展 | | commander | 命令行自定义指令 | | inquire | 命令行询问用户问题,记录回答结果 |

脚手架命令注册

上面的准备工作全部通过之后,我们开始注册命令。这里我们使用commander来帮助我们注册一个init [projectName]命令和option:forceoption:debug两个option。这样我们就可以使用下面命名进行交互

spike-cli init testProject --debug --force

其中--debug--force可不传。--debug参数可开启debug模式。--force参数可强制初始化项目。

下面我们来看看命令注册代码:

async function core() {
  try {
    await prepare();
+   registerCommand();
  } catch (e) {
    log.error(e.message);
  }
}

// 注册command命令
function registerCommand() {
  program
    .name(Object.keys(pkg.bin)[0])
    .usage('<command> [options]')
    .version(pkg.version)
    .option('-d, --debug', '是否开启调试模式', false)

  // 注册 init command
  program
    .command('init [projectName]')
    .option('-f --force', '是否强制初始化项目')
    .action(init)

  // 监听 degub option
  program.on('option:debug', function() {
    if (program._optionValues.debug) {
      process.env.LOG_LEVEL = 'verbose';
    }
    log.level = process.env.LOG_LEVEL;
    log.verbose('开启debug模式')
  })

  // 监听未知命令
  program.on('command:*', function (obj) {
    const availableCommands = program.commands.map(cmd => cmd.name());
    console.log(colors.red('未知的命令:' + obj[0]));
    if (availableCommands.length > 0) {
      console.log(colors.green('可用的命令:' + availableCommands.join(',')));
    }
    if (program.args && program.args.length < 1) {
      program.outputHelp();
    }
  })

  program.parse(process.argv);
}

脚手架命令执行

当我们监听到init [projectName]命令时,我们会执行init函数。下面我们来看看init函数的代码:

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);
}

init函数会接受command参数,由于command参数内容过多,只留下有用的optionValues参数。再执行InitCommand并传入简化过的参数。

下面我们来看看InitCommand内部代码:

class InitCommand {
  constructor(argv) {
    if (!Array.isArray(argv)) {
      throw new Error('参数必须为数组!');
    }
    if(!argv || argv.length < 1) {
      throw new Error('参数不能为空!');
    }
    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.交互阶段
      const projectInfo = await this.interaction();
      this.projectInfo = projectInfo;
      if (projectInfo) {
        log.verbose('projectInfo', projectInfo);
       // 2.下载模板
        await this.downloadTemplate();
        // 3.安装模板
        await this.installTemplate();
      }
    } catch (e) {
      log.error(e.message);
    }
  }
}

这段代码是命令执行的核心流程。首先进行参数的校验和参数的整合,之后就进入到交互阶段下载模板阶段安装模板阶段

交互阶段

进入到交互阶段,我们首先要判断当前目录是否为空,如果为空是否启动强制更新,再二次确认是否清空当前目录。这里我们需要用到inquire,让他来帮助我们解决命令行交互的问题。下面我们来看代码:

async interaction() {
  // 1.判断当前目录是否为空
  const localPath = process.cwd();
  if (!this.isDirEmpty(localPath)) {
    // 2.是否启动强制更新
    let isContinue = false;
    if(!this.force) {
      isContinue = (await inquirer.prompt({
        type: 'confirm',
        name: 'isContinue',
        message: '当前文件夹不为空,是否继续创建项目?',
        default: false
      })).isContinue;

      if (!isContinue) return;
    }

    if (isContinue || this.force) {
      // 3.二次确认是否清空当前目录
      const { confirmDelete } = await inquirer.prompt({
        type: 'confirm',
        name: 'confirmDelete',
        default: false,
        message: '是否确认清空当前目录下的文件?'
      })
      if (confirmDelete) {
        fse.emptyDirSync(localPath);
      }
    }
  }

  return this.getProjectInfo();
}

如果用户继续选择创建时,我们就要执行getProjectInfo函数。它会询问用户项目名称、项目版本和选择的模板,并返回项目信息。

我们需要提前将模板上传到npm上,并准备好模板信息。

const template = [
  {
    name: 'react标准模板',
    npmName: 'spike-cli-template-react',
    version: '1.0.0',
    installCommand: 'npm install',
    startCommand: 'npm run start'
  },
  {
    name: 'react+redux模板',
    npmName: 'spike-cli-template-react-redux',
    version: '1.0.0',
    installCommand: 'npm install',
    startCommand: 'npm run start'
  },
  {
    name: 'vue3标准模板',
    npmName: 'spike-cli-template-vue3',
    version: '1.0.0',
    installCommand: 'npm install',
    startCommand: 'npm run serve'
  }
]

module.exports = template;

下来我们来看看具体代码:

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: '请输入项目名称',
      default: '',
      validate: function (v) {
        const done = this.async();
        setTimeout(() => {
          // 1.首字母必须为英文字母
          // 2.尾字母必须为英文或数字,不能为字符
          // 3.字符仅允许 "-_"
          // 合法:a, a-b, a_b, a-b-c, a_b_c, a-b1-c1, a_b1_c1
          // 不合法:1, a_, a-, a_1, a-1
          if (!isValidName(v)) {
            done('请输入合法的项目名称');
            return;
          }
          done(null, true);
        }, 0);
      },
      filter: function (v) {
        return v;
      }
    })
  }

  projectPrompts.push({
    type: 'input',
    name: 'projectVersion',
    message: '请输入项目版本号',
    default: '1.0.0',
    validate: function (v) {
      const done = this.async();
      setTimeout(() => {
        if (!semver.valid(v)) {
          done('请输入合法的版本号');
          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: '请选择项目模板',
    choices: this.createTemplateChoices()
  })

  const project = await inquirer.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
}

下载模板

和用户进行交互之后,我们就开始下载选择的项目模板。这里借助了npmInstall,来帮我们下载。下面我们来看看代码:

async downloadTemplate() {
  const { projectTemplate } = this.projectInfo;
  this.templateInfo = template.find(item => item.npmName === projectTemplate);
  const spinner = spinnerStart('正在下载模板...');
  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('下载模板成功!');
    }
  }
}

这里的模板会被下载到node_modules下,我们提前拼接好模板路径,为接下来拷贝模板至当前目录做准备。

安装模板

我们首先拷贝模板代码至当前目录。然后使用ejs将packageName和version渲染到package.json中。再执行安装命令和启动命令。

下面我们来看看代码:

async installTemplate() {
  let spinner = spinnerStart('正在安装模板...');
  await sleep();
  try {
    // 拷贝模板代码至当前目录
    fse.copySync(this.templatePath, process.cwd());
  } catch (e) {
    throw new Error(e);
  }finally {
    spinner.stop(true);
    log.success('模板安装成功!');
  }

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

  const { installCommand, startCommand } = this.templateInfo;
  // 安装命令
  await this.exexCommand(installCommand, '依赖安装过程中失败!');
  // 启动命令
  await this.exexCommand(startCommand, '依赖安装过程中失败!')
}

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))
    })
  })
}

这里代码看上去稍多,但是核心逻辑上面已经讲明了。其中只有exexCommand函数没有细讲。它的作用是执行传入的node命令,其核心是使用node内置child_process模块中的spawn方法。

下面我们来看看具体代码:

async exexCommand(command, errMsg) {
  let ret;
  if(command) {
    const cmdArray = command.split(' ');
    const cmd = this.checkCommand(cmdArray[0]);
    if(!cmd) {
      throw new Error('命令不存在!命令:' + 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 || {});
}

到这为止,我们的脚手架就开发完了,之后就可以发布到npm上了。

总结

笔者已经将spike-cli发布到npm上了。有兴趣的读者可以使用下面的命令体验一下:

npm install spike-cli -g
spike-cli init testProject

spike-cli脚手架是笔者根据自己的需求设计的,各位读者也可以根据自己的需求设计相应的环节。

希望本文可以帮助读者搭建自己的脚手架,提高开发效率。