[译] 如何使用 Node.js 构建一个命令行应用(CLI)

atZ3n9vMFjjXDl_XxDtL_FCRSOt6EF0d8LnbMRCCJQUesMme8lzdGpCyMr4-wt1nlIGuoT29EI_tkVpuD_P2mxzbfhbn-ZPcqmZ5QCY_nM9d4ywWEYQxKYc9mjxUnp_uFJzMOMnr

Node.js 内建的命令行应用(CLI)让你可以在使用其庞大的生态系统的同时自动化地执行重复性的任务。而且,多亏了像 npmyarn 这样的包管理工具,让这些命令行应用能够很容易就在多个平台上分发和使用。在本篇文章中,我将会讲述为什么须要写 CLI,如何使用 Node.js 完成它,一些实用的包,以及你如何发布你新写好的 CLI。javascript

为何要用 Node.js 建立命令行应用

Node.js 可以如此流行的缘由之一就是它有丰富的包生态系统,现在在 npm 注册处 已经有超过 900000 个包。经过在 Node.js 中写你本身的 CLI,你就能够进入这个生态系统,而其中也包含了巨额数目的针对 CLI 的包。包括:html

  • inquirerenquirer 或者 prompts,可用于处理复杂的输入提示
  • email-prompt 可方便地提示邮箱输入
  • chalkkleur 可用于彩色输出
  • ora 是一个好看的加载提示
  • boxen 能够用于在你的输出外加上边框
  • stmux 能够提供一个和 tmux 相似的多终端界面
  • listr 能够展现进程列表
  • ink 可使用 React 构建 CLI
  • meow 或者 arg 能够用于基本的参数解析
  • commanderyargs 能够用来比较复杂的参数解析,并支持子命令
  • oclif 是一个用于构建可扩展 CLI 的框架,做者是 Heroku(gluegun 可做为替换方案)

还有不少方便的方法能够用来使用 CLI,它们都发布在 npm 上,能够同时使用 yarnnpm 进行管理。例如 create-flex-plugin,是一个能够用来为 Twilio Flex 建立插件的 CLI。你可使用全局命令来安装它:前端

# 使用 npm 安装:
npm install -g create-flex-plugin
# 使用 yarn 安装:
yarn global add create-flex-plugin
# 安装以后你就可使用了:
create-flex-plugin
复制代码

或者它也能够做为项目依赖:java

# 使用 npm 安装:
npm install create-flex-plugin --save-dev
# 使用 yarn 安装:
yarn add create-flex-plugin --dev
# 安装以后命令将被保存在
./node_modules/.bin/create-flex-plugin
# 或者经过由 npm 支持的 npx 使用:
npx create-flex-plugin
# 以及经过 yarn 使用:
yarn create-flex-plugin
复制代码

事实上,npx 能支持在没有安装的时候就执行 CLI。只须要运行 npx create-flex-plugin,这时候若是找不到本地或者全局的已安装版本,它将会自动下载这个包并放入缓存中。node

npm 6.1 版本后,npm inityarn 都支持使用 CLI 来构建项目,命令的名字形如 create-*。例如,刚才说的 create-flex-plugin,咱们要作的就是:react

# 使用 Node.js:
npm init flex-plugin
# 使用 Yarn:
yarn create flex-plugin
复制代码

构建第一个 CLI

若是你更喜欢看视频学习,点击这里在 YouTube 观看教程android

目前咱们已经解释过用 Node.js 建立 CLI 的缘由,如今就让咱们开始构建一个 CLI 吧。在本篇教程里,咱们会使用 npm,但若是你想用 yarn,绝大多数的命令也都是相同的。确保你的系统中已经安装了 Node.jsnpmios

本篇教程中,咱们将会建立一个 CLI,经过运行命令 npm init @your-username/project,它能够根据你的偏好构建一个新的项目。git

经过运行以下代码,开始一个新的 Node.js 项目:es6

mkdir create-project && cd create-project
npm init --yes
复制代码

以后在项目的根目录下建立一个名为 src/ 的目录,而后将一个名为 cli.js 的文件放在这个目录下,并在文件中写入代码:

export function cli(args) {
 console.log(args);
}
复制代码

在这个函数中,咱们将会解析参数逻辑并触发实际须要的业务逻辑。接下来,咱们须要建立 CLI 的入口。在项目根目录下建立目录 bin/ 而后建立一个名为 create-project 的文件。写入代码:

#!/usr/bin/env node

require = require('esm')(module /*, options*/);
require('../src/cli').cli(process.argv);
复制代码

在这一小片代码中,完成了几件事情。首先,咱们引入了一个名为 esm 的模块,这个模块让咱们能在其余文件中使用 import。这和构建 CLI 并不直接相关,可是本篇教程中咱们须要使用 ES 模块,而包 esm 让咱们能在 Node.js 版本不支持时无需代码转换而使用 ES 模块。而后咱们引入 cli.js 文件并调用函数 cli,并将 process.argv 传入,它是从命令行传入函数脚本的参数数组。

在咱们测试脚本以前,须要经过运行以下命令安装 esm 依赖:

npm install esm
复制代码

另外,咱们还要将暴露 CLI 脚本的需求同步给包管理器。方法是在 package.json 文件中添加合适的入口。别忘了也要更新属性 descriptionnamekeywordmain

{
 "name": "@your_npm_username/create-project",
 "version": "1.0.0",
 "description": "A CLI to bootstrap my new projects",
 "main": "src/index.js",
 "bin": {
   "@your_npm_username/create-project": "bin/create-project",
   "create-project": "bin/create-project"
 },
 "publishConfig": {
   "access": "public"
 },
 "scripts": {
   "test": "echo \"Error: no test specified\" && exit 1"
 },
 "keywords": [
   "cli",
   "create-project"
 ],
 "author": "YOUR_AUTHOR",
 "license": "MIT",
 "dependencies": {
   "esm": "^3.2.18"
 }
}
复制代码

若是你注意到 bin 属性,你会发现咱们将其定义为一个具备两个键值对的对象。这个对象内定义的是包管理器将会安装的 CLI 命令。在上述的例子中,咱们为同一段脚本注册了两个命令。一个经过加上了咱们的用户名来使用本身的 npm 做用域,另外一个是为了方便使用的通用的 create-project 命令。

作好了这些,咱们能够测试脚本了。最简单的测试方法是使用 npm link 命令。在你的项目终端中运行:

npm link
复制代码

这个命令将会全局地安装你当前项目的连接,因此当你更新代码的时候,也并不须要从新运行 npm link 命令。在运行 npm link 命令后,你的 CLI 命令应该已经可用了。试着运行:

create-project
复制代码

你应该能够看到相似的输出:

[ '/usr/local/Cellar/node/11.6.0/bin/node',
  '/Users/dkundel/dev/create-project/bin/create-project' ]
复制代码

注意,这两个地址依赖于你的项目地址和 Node.js 安装地址,并会随之变化而不一样。而且这个数组会随着你增长参数而变长。试试运行:

create-project --yes
复制代码

此时输出能够反映出添加了新的参数:

[ '/usr/local/Cellar/node/11.6.0/bin/node',
  '/Users/dkundel/dev/create-project/bin/create-project',
  '--yes' ]
复制代码

参数解析与输入处理

如今咱们准备解析传入脚本的参数,并赋予其逻辑意义。咱们的 CLI 支持一个参数及多个选项:

  • [template]:咱们支持开箱即用的多模版。若是用户没有传入这个参数,咱们将给出提示让用户选择
  • --git:它将会运行 git init,来实例化一个新的 git 项目
  • --install:它将会自动地为项目安装全部依赖
  • --yes:它将会跳过全部提示,直接使用默认选项

对于咱们的项目,将会使用 inquirer 来提示输入参数,并使用 arg 库来解析 CLI 参数。经过运行以下命令来安装依赖:

npm install inquirer arg
复制代码

首先咱们来写解析参数的逻辑,解析过程将会把参数解析为一个 options 对象,供咱们使用。将以下代码加入到 cli.js 中:

import arg from 'arg';

function parseArgumentsIntoOptions(rawArgs) {
 const args = arg(
   {
     '--git': Boolean,
     '--yes': Boolean,
     '--install': Boolean,
     '-g': '--git',
     '-y': '--yes',
     '-i': '--install',
   },
   {
     argv: rawArgs.slice(2),
   }
 );
 return {
   skipPrompts: args['--yes'] || false,
   git: args['--git'] || false,
   template: args._[0],
   runInstall: args['--install'] || false,
 };
}

export function cli(args) {
 let options = parseArgumentsIntoOptions(args);
 console.log(options);
}
复制代码

运行 create-project --yes,你将能看到 skipPrompt 会变成 true,或者试着传递其余参数例如 create-project cli,那么 template 属性就会被设置。

如今咱们已经能解析 CLI 参数了,咱们还须要添加方法来提示用户输入参数信息,以及当 --yes 标志被输入的时候,略过提示信息并使用默认参数。将以下代码加入 cli.js 文件:

import arg from 'arg';
import inquirer from 'inquirer';

function parseArgumentsIntoOptions(rawArgs) {
// ...
}

async function promptForMissingOptions(options) {
 const defaultTemplate = 'JavaScript';
 if (options.skipPrompts) {
   return {
     ...options,
     template: options.template || defaultTemplate,
   };
 }

 const questions = [];
 if (!options.template) {
   questions.push({
     type: 'list',
     name: 'template',
     message: 'Please choose which project template to use',
     choices: ['JavaScript', 'TypeScript'],
     default: defaultTemplate,
   });
 }

 if (!options.git) {
   questions.push({
     type: 'confirm',
     name: 'git',
     message: 'Initialize a git repository?',
     default: false,
   });
 }

 const answers = await inquirer.prompt(questions);
 return {
   ...options,
   template: options.template || answers.template,
   git: options.git || answers.git,
 };
}

export async function cli(args) {
 let options = parseArgumentsIntoOptions(args);
 options = await promptForMissingOptions(options);
 console.log(options);
}
复制代码

保存文件并运行 create-project,你将会看到这样的模版选择提示:

以后,你将会被询问是否要初始化 git。两个问题都做出先择后,你将看到打印出了这样的输出:

{ skipPrompts: false,
  git: false,
  template: 'JavaScript',
  runInstall: false }
复制代码

尝试运行 create-project -y 命令,此时全部的提示都会被忽略。你将会立刻看到命令行输入的选项:

编写代码逻辑

如今咱们已经能够经过提示信息以及命令行参数来决定对应的逻辑选项,下面咱们来写可以建立项目的逻辑代码。咱们的 CLI 将会和 npm init 命令相似,写入一个已经存在的目录,并会将全部在 templates 目录下的文件拷贝到项目中。咱们也容许经过选项修改目标目录地址,这样你能够在其余项目中重用这段逻辑。

在咱们写逻辑代码以前,在项目根目录下建立一个名为 templates 的目录,并将目录 typescriptjavascript 放在此目录下。它们的名字都是小写的版本,咱们将会提示用户从中选择一个。在本篇文章中,咱们就使用这两个名字,但其实你可使用你任意喜欢的命名。在这个目录下,放入文件 package.json 并加入任意你须要的项目基础依赖,以及任意你须要拷贝到项目中的文件。以后咱们的代码将会把这些文件全都拷贝到新的项目中。若是你须要一些创做灵感,你能够在 github.com/dkundel/create-project 查看我使用的文件。

为了递归的拷贝全部的文件,咱们将会使用一个名为 ncp 的库。这个库可以支持跨平台的递归拷贝,甚至有标识能够支持强制覆盖已有文件。另外,为了可以展现彩色输出,咱们还将安装 chalk。运行以下代码来安装依赖:

npm install ncp chalk
复制代码

咱们将会把项目核心的逻辑都放到 src/ 目录下的 main.js 文件中。建立新文件并将以下代码加入:

import chalk from 'chalk';
import fs from 'fs';
import ncp from 'ncp';
import path from 'path';
import { promisify } from 'util';

const access = promisify(fs.access);
const copy = promisify(ncp);

async function copyTemplateFiles(options) {
 return copy(options.templateDirectory, options.targetDirectory, {
   clobber: false,
 });
}

export async function createProject(options) {
 options = {
   ...options,
   targetDirectory: options.targetDirectory || process.cwd(),
 };

 const currentFileUrl = import.meta.url;
 const templateDir = path.resolve(
   new URL(currentFileUrl).pathname,
   '../../templates',
   options.template.toLowerCase()
 );
 options.templateDirectory = templateDir;

 try {
   await access(templateDir, fs.constants.R_OK);
 } catch (err) {
   console.error('%s Invalid template name', chalk.red.bold('ERROR'));
   process.exit(1);
 }

 console.log('Copy project files');
 await copyTemplateFiles(options);

 console.log('%s Project ready', chalk.green.bold('DONE'));
 return true;
}
复制代码

这段代码会导出一个名为 createProject 的新函数,这个函数会首先检查指定的模版是不是可用的,检查的方法是使用 fs.access 来检查文件的可读性(fs.constants.R_OK),而后使用 ncp 将文件拷贝到指定的目录下。另外,在拷贝成功后,咱们还要输出一些带颜色的日志,内容为 DONE Project ready

以后,更新 cli.js,加入对新函数 createProject 的调用:

import arg from 'arg';
import inquirer from 'inquirer';
import { createProject } from './main';

function parseArgumentsIntoOptions(rawArgs) {
// ...
}

async function promptForMissingOptions(options) {
// ...
}

export async function cli(args) {
 let options = parseArgumentsIntoOptions(args);
 options = await promptForMissingOptions(options);
 await createProject(options);
}
复制代码

为了测试咱们的进度,在你的系统中某个位置例如 ~/test-dir 中建立一个新目录,而后在这个文件夹内使用某个模版运行命令。好比:

create-project typescript --git
复制代码

你应该能看到一个通知,代表项目已经被建立,而且文件已经被拷贝到了这个目录下。

如今还有另外两步须要作。咱们但愿可配置的初始化 git 并安装依赖。为了完成这个,咱们须要另外三个依赖:

  • execa 用于让咱们能在代码中很便捷的运行像 git 这样的外部命令
  • pkg-install 用于基于用户使用什么而触发命令 yarn installnpm install
  • listr 让咱们能指定任务列表,并给用户一个整齐的进程概览

经过运行以下命令来安装依赖:

npm install execa pkg-install listr
复制代码

以后更新 main.js,加入以下代码:

import chalk from 'chalk';
import fs from 'fs';
import ncp from 'ncp';
import path from 'path';
import { promisify } from 'util';
import execa from 'execa';
import Listr from 'listr';
import { projectInstall } from 'pkg-install';

const access = promisify(fs.access);
const copy = promisify(ncp);

async function copyTemplateFiles(options) {
 return copy(options.templateDirectory, options.targetDirectory, {
   clobber: false,
 });
}

async function initGit(options) {
 const result = await execa('git', ['init'], {
   cwd: options.targetDirectory,
 });
 if (result.failed) {
   return Promise.reject(new Error('Failed to initialize git'));
 }
 return;
}

export async function createProject(options) {
 options = {
   ...options,
   targetDirectory: options.targetDirectory || process.cwd()
 };

 const templateDir = path.resolve(
   new URL(import.meta.url).pathname,
   '../../templates',
   options.template
 );
 options.templateDirectory = templateDir;

 try {
   await access(templateDir, fs.constants.R_OK);
 } catch (err) {
   console.error('%s Invalid template name', chalk.red.bold('ERROR'));
   process.exit(1);
 }

 const tasks = new Listr([
   {
     title: 'Copy project files',
     task: () => copyTemplateFiles(options),
   },
   {
     title: 'Initialize git',
     task: () => initGit(options),
     enabled: () => options.git,
   },
   {
     title: 'Install dependencies',
     task: () =>
       projectInstall({
         cwd: options.targetDirectory,
       }),
     skip: () =>
       !options.runInstall
         ? 'Pass --install to automatically install dependencies'
         : undefined,
   },
 ]);

 await tasks.run();
 console.log('%s Project ready', chalk.green.bold('DONE'));
 return true;
}
复制代码

这段代码将会在传入 --git 或者用户在提示中选择了 git 的时候运行 git init,而且会在传入 --install 的时候运行 npm install 或者 yarn,不然它将会跳过这两个任务,并用一段消息通知用户若是他们想要自动安装,请传入 --install

首先删除掉已经存在的测试文件夹而后建立一个新的,而后试一下效果如何。运行命令:

create-project typescript --git --install
复制代码

在你的文件夹中,你应该能看到 .git 文件夹和 node_modules 文件夹,表示 git 已经被初始化,以及 package.json 中指定的依赖已经被安装了。

恭喜你,你的第一个 CLI 已经整装待发了!

若是你但愿你的代码能做为实际的模块使用,这样其余人能够在他们的代码中复用你的逻辑,你还须要在目录 src/ 下添加文件 index.js,这个文件暴露出了 main.js 的内容:

require = require('esm')(module);
require('../src/cli').cli(process.argv);
复制代码

接下来作什么?

如今你的 CLI 代码已经准备好,你能够由此为基础,向更多的方向发展。若是你仅仅想本身使用,而不想和其余人分享,那么你就须要继续沿用 npm link 便可。事实上,运行 npm init project 试试看,你的代码也将被触发。

若是你想要和其余人分享你的代码模版,你能够将代码推送到 GitHub 来供参阅,或者更好的方法是,使用 npm publish 将它做为一个包推送到 npm 注册处。在你发布以前,你还须要确保在 package.json 文件中添加一个 files 属性,来指明那些文件应该被发布:

},
 "files": [
   "bin/",
   "src/",
   "templates/"
 ]
}
复制代码

若是你想要检查那个文件将会被发布,运行 npm pack --dry-run 而后查看输出。以后使用 npm publish 来发布你的 CLI。你能够在 @dkundel/create-project 找到个人项目,或者试试看运行 npm init @dkundel/project

还有不少的功能你能够加入进来。在个人项目中,我还添加了一些依赖,用于为我建立 LICENSECODE_OF_CONDUCT.md.gitignore。你能够在 GitHub 找到实现这些功能的源代码,或着查看上面提到的仓库来扩充附加功能。若是你发现某个你以为应该被列出在文章中而我并无列出的库,或者想要给我看你的 CLI,尽管发送消息给我!

使用 JavaScript 还能够构建更多:

若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索