1.1.0 • Published 4 years ago

@stellastella/xj-cli v1.1.0

Weekly downloads
-
License
ISC
Repository
github
Last release
4 years ago

实现简易的 CLI

CLI 全称 command-line interface 命令行界面,通常不支持鼠标,用户通过键盘输入指令,计算机收到指令,予以执行。

本文结合 vue-clicreate-react-app 即脚手架,定制个人项目脚手架,快速创建初始化项目文件。

定制脚手架初衷: 1. 快速搭建项目初始化文件; 2. 统一代码规范。

Tip: 文章旨在记录个人开发 cli 的经验。后续文章提及的 xj-cli 为个人开发脚手架示例,可在 npm 上 download 之后,就能快速生成项目。

xj-cli

开发之前,需创建一个 Github 组织 organization 账户(如果你有,即可跳过)。先创建一个普通 Github 账户,并升级为 organization 组织账户,后续会将个人初始化后的项目放入该组织账户下,供脚手架逻辑使用。

下载依赖包

  • axios: http 库
  • commander: 命令行参数解析
  • consolidate: 统一模板引擎
  • download-git-repo: 下载并提取出 Git 仓库代码
  • ejs: 模板引擎
  • inquirer: 交互式命令行,实现命令行选择功能
  • metalsmith: 极简、插件化的静态站点生成器

Metalsmith (译自官网) 为什么 Metalsmith 是一个插件化的静态站点生成器?

  • 从事源码目录中读取源文件,抽取信息;
  • 可操作抽取的信息;
  • 将操作后的信息写入文件,最后移至目标目录。

初始化文件

目录

├── bin
│   └── www  
├── package.json
├── src
│   ├── main.js 
│   ├── create.js   
│   ├── config.js   
│   └── constants.js  

配置项

package.json

package.json 是整个项目的配置文件,通过命令行 npm init -y 快速生成。

"bin": {
  "xj-cli": "./bin/www"
},
""

Tip: bin 属性是为包配置执行环境与入口文件,在 shell 下执行 xj-cli 时就能调用 bin 目录下的 www 文件

bin/www

#! /usr/bin/env node
require('../src/main.js');

Tip: 顶部需添加 #! /usr/bin/env node,标识命令行输入 xj-cli 时,以 node 环境执行此文件

链接全局包

该步骤是将当前 xj-cli 临时配置到执行环境变量中,实现在任意目录 shell 下都能执行 xj-cli 命令。

npm link

核心代码

功能

  • xj-cli --version 查看版本号
  • xj-cli --help 查看帮助
  • xj-cli config xxx 设置配置项
  • xj-cli create xxx 创建项目

constants.js

const { version } = require('../package.json');

const downloadDirectory = `${process.env[process.platform === 'darwin' ? 'HOME' : 'USERPROFILE']}/.template`

module.exports = {
  version,
  downloadDirectory,
};

导出版本号,下载创建项目缓存目录

const program = require('commander');
const path = require('path');
const { version } = require('./constants');

const mapActions = {
  create: {
    alias: 'cre',
    description: 'create a project',
    examples: [
      'xj-cli create <project-name>',
    ],
  },
  config: {
    alias: 'conf',
    description: 'config project variable',
    examples: [
      'xj-cli config set <k> <v>',
      'xj-cli config get <k>',
    ],
  },
  '*': {
    alias: '',
    description: 'command not found',
    examples: [],
  },
};

Reflect
  .ownKeys(mapActions)
  .forEach(
    (action) => {
      program
        .command(action)
        .alias(mapActions[action].alias)
        .description(mapActions[action].description)
        .action(() => {
          if (action === '*') {
            console.log(mapActions[action].description);
          } else {
            require(path.resolve(__dirname, action))(...process.argv.slice(3));
          }
        });
    },
  );

program.on('--help', () => {
  console.log('\nExamples:');
  Reflect.ownKeys(mapActions).forEach((action) => {
    mapActions[action].examples.forEach((example) => console.log(`  ${example}`));
  });
});

program
  .version(version)
  .parse(process.argv);

create.js 引入依赖文件

const axios = require('axios');
const ora = require('ora');
const Inquirer = require('inquirer');
let downloadGitRepo = require('download-git-repo');
const fs = require('fs');
const { promisify } = require('util');
const path = require('path');
const MetalSmith = require('metalsmith');
let { render } = require('consolidate').ejs;
const ncp = require('ncp');
const { downloadDirectory } = require('./constants');

包装原函数为返回 promise 对象的新函数,避免回调函数嵌套

render = promisify(render);
downloadGitRepo = promisify(downloadGitRepo);

异步获取仓库项目列表,tag 列表

const fetchRepoList = async () => {
  const { data } = await axios.get('https://api.github.com/orgs/xj-cli/repos');
  return data;
};

const fetchTagList = async (repo) => {
  const { data } = await axios.get(`https://api.github.com/repos/xj-cli/${repo}/tags`);
  return data;
};

基于函数柯里化设计模式,封装异步获取前后的 loading 图标显示

const waitFnLoading = (fn, message) => async (...args) => {
  const spinner = ora(message);
  spinner.start();
  const result = await fn(...args);
  spinner.succeed();
  return result;
};

const download = async (repo, tag) => {
  let api = `xj-cli/${repo}`;
  if (tag) {
    api += `#${tag}`;
  }
  const dest = `${downloadDirectory}/${repo}`;
  await downloadGitRepo(api, dest);
  return dest;
};

function checkProjectName(projectName) {
  if (!projectName) {
    console.error('请输入要创建的项目名称');
    return false;
  }
  return true;
}

导出一个函数,接收项目名,开始远程拉取项目,进行创建

  • 拉取项目列表名,交互式问答得出用户想要下载的项目名
  • 拉取用户选择的项目 tag 版本号
  • 下载代码缓存在本地,并在当前目录下生成项目
module.exports = async (projectName) => {
  if (!checkProjectName(projectName)) return;

  let repos = await waitFnLoading(fetchRepoList, 'fetching template...')();
  repos = repos.map((item) => item.name);
  const { repo } = await Inquirer.prompt({
    name: 'repo',
    type: 'list',
    message: '请选择要下载的模板',
    choices: repos,
  });
  let tags = await waitFnLoading(fetchTagList, 'fetching tags...')(repo);
  tags = tags.map((item) => item.name);

  const { tag } = await Inquirer.prompt({
    name: 'tag',
    type: 'list',
    message: '请选择要下载的版本',
    choices: tags,
  });

  const result = await waitFnLoading(download, 'downloading template')(repo, tag);
  if (!fs.existsSync(path.join(result, 'ask.js'))) {
    await ncp(result, path.resolve(projectName));
  } else {
    await new Promise((resolve, reject) => {
      MetalSmith(__dirname)
        .source(result)
        .destination(path.resolve(projectName))
        .use(async (files, metal, done) => {
          const args = require(path.join(result, 'ask.js'));
          const obj = await Inquirer.prompt(args);
          const meta = metal.metadata();
          Object.assign(meta, obj);
          Reflect.deleteProperty(files, 'ask.js');
          done();
        })
        .use((files, metal, done) => {
          const obj = metal.metadata();
          Reflect.ownKeys(files).forEach(async (file) => {
            if (file.includes('js') || file.includes('json')) {
              let content = files[file].contents.toString();
              if (content.includes('<%')) {
                content = await render(content, obj);
                files[file].contents = Buffer.from(content);
              }
            }
          });
          done();
        })
        .build((err) => {
          if (err) {
            reject();
          } else {
            resolve();
          }
        })
    });
  }
};

发包

nrm use npm
npm login
npm publish

Tip: 登录 npm 账号前,切换到npm 源下

nrmList

使用

全局安装 xj-cli 包

nmp i xj-cli -g

下载包

xj-cli create app

fetchingRepo

selectRepo

tag

downloading

到这里核心功能实现了,后续有其他命令可在此基础上扩展即可。

补充

如果发布 scoped packages 作用域包到 NPM 包管理器上,点击 创建scoped packages,通过以下步骤即可把当前的 user 转成 org 组织(如图):

create an org

  • 输入要新创建的 org 名,如:stellastella
  • 勾选 FREE 选项 -(可选)CONVERT - 勾选,将当前的个人账号 @stella2 转为 org,新创建的 stellastella 为个人账号

修改配置文件信息

{
  "name": "@stellastella/xj-cli"
}

这一步也能通过 NPM 命令行实现:

npm publish --access public

至此,scoped packages 完成发布。