别人休息我努力,悄悄写个 cli 工具,必须提升效率,skr~
版权声明:本人文章仅在掘金平台发布,请勿抄袭搬运,转载请注明作者及原文链接 🦉
阅读提示:网页版带有主题和代码高亮,阅读体验更佳 🍉
如果你不想看文章,可以直接阅读源码:xwg-cli[1],如果有收获请点个 star。
开门见山,先看效果:
xwg \--help
:
xwg \--version
:
xwg create demo
:
xwg create \--help
:
再来看看模板的质量如何:
每份模板都是我精心准备的,具备较为完善的工程化能力,包括 eslint
,commitlint
,git cz
,CHANGELOG
等。但是也没有过多地干涉你的使用,并不是完全强制的。例如,虽然我配置了 eslint
,但是没有强制规则,你可以自己增减。
koa:
搭建了完整的 koa
开发结构,内置了一个 User
模块,开箱即用。像 sequelize
、log4js
等功能都是完备可用的,是不是非常用心,有木有!!
uniapp + vue2 + uview:
uniapp 这份 vue2 版本的模板,基于我自己的商业项目进行改造,像请求模块都是内置好的。另外,这个模板还加入了 uview-ui
,省去了你寻找 UI 添加组件库的步骤,关键是添加 UI 组件库真的有不少坑,用了这个模板直接快人一步,少踩起码 10 个坑。
在介绍开发代码之前,想必你也想先尝试下效果。
首先,确保你的 node
版本是 14.18+
或者 16+
。
执行 yarn add xwg-cli \-g
,重新打开终端执行 xwg \--version
看看是否成功安装。目前支持的命令有:
xwg \--help
xwg \--version
xwg create <项目名>
其实 --help
,--version
是自带的(后面会详细讲),这两个命令更严谨的说法是参数,参数一般就是 --
开头,只有 create
才算是命令,目前 cli 仅完成了创建项目的能力,其他的能力正在思考建设中。
如果你不想下载,也可以执行 npx xwg-cli create demo
尝试创建项目。想必你又有疑问,为什么我的 cli 叫作 xwg-cli
,但是我执行的时候是 xwg xxx
,为什么不是 xwg-cli xxx
?这些问题都将在我们接下来的文章中解答。
接下来的内容会比较长,请坐好小板凳。
如何搭建项目的开发环境cli 工具本质是一个 npm 库,那么我们就应该按照库的模式搭建项目开发环境。如果你不知道如何搭建,请参考我的文章:开发一个 npm 库应该做哪些工程配置?[2]。如果你不是很想从头搭建开发环境,那么,你可以直接 fork 我的仓库:xwg-cli[3]。
我的项目使用了 ts,但是不复杂,基本和 js 差不多,但是这仍然要求你具备比较好的 ts 知识,否则阅读文章会有一点难度。
需要用到的第三方库库名 | 作用 |
---|---|
commander |
解析用户在终端输入的命令及参数,例如 create、--help 等 |
chalk |
为终端输出的文字增加各种各样的颜色 |
inquirer |
给用户提供各种交互,包括输入、选择等,例如提示用户选择项目的创建类型,其可以提供输入框、radio、checkbox 等强大的交互控件 |
ora |
增加 loading,例如下载模板时增加 loading |
fs-extra |
fs 模块的增强,支持更强大的文件读写操作 |
download-git-repo |
下载模板 |
chalk
我下载的 chalk
的大版本是 4
,类型定义可能和其它版本不一样,具体请以你下载的版本为准。
import chalk from 'chalk';
console.log(chalk.red('这会输出红色的字'))
console.log(chalk.blue('这会输出蓝色的字'))
console.log(chalk.cyan('这会输出青色的字'))
console.log(chalk.cyan.bold('这会输出青色加粗的字'))
console.log(chalk.cyan.italic('这会输出青色斜体的字'))
chalk 改变字体颜色的所有方法定义:
declare type ForegroundColor =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white'
| 'gray'
| 'grey'
| 'blackBright'
| 'redBright'
| 'greenBright'
| 'yellowBright'
| 'blueBright'
| 'magentaBright'
| 'cyanBright'
| 'whiteBright';
chalk 改变字体背景色的所有方法定义:
declare type BackgroundColor =
| 'bgBlack'
| 'bgRed'
| 'bgGreen'
| 'bgYellow'
| 'bgBlue'
| 'bgMagenta'
| 'bgCyan'
| 'bgWhite'
| 'bgGray'
| 'bgGrey'
| 'bgBlackBright'
| 'bgRedBright'
| 'bgGreenBright'
| 'bgYellowBright'
| 'bgBlueBright'
| 'bgMagentaBright'
| 'bgCyanBright'
| 'bgWhiteBright';
chalk 改变字体属性所有方法的定义:
declare type Modifiers =
| 'reset'
| 'bold'
| 'dim'
| 'italic'
| 'underline'
| 'inverse'
| 'hidden'
| 'strikethrough'
| 'visible';
了解这些基本已经足够你开发使用了。
commander
import { program } from 'commander';
program.name
定义命令的名称。
program.name(chalk.cyan('xwg'))
program.name(chalk.cyan('demo'))
image.png
image.png
program.usage
定义命令的用法。
program.name(chalk.cyan('xwg')).usage(`${chalk.yellow('<command>')} [options]`);
image.png
program.version
定义 --version
。
program.version(
`\r\n ${chalk.cyan.bold(VERSION)}
${chalk.cyan.bold(BRAND_LOGO)}`
);
image.png
program.on
监听某个参数的执行,并执行回调。
program.on("--help", function () {
console.log(`\r\n终端执行 ${chalk.cyan.bold("xwg <command> --help")} 获取更多命令详情\r\n`);
});
当我们监听某个命令时,可以这么写:
program
.command('create <project-name>') // 这里不能使用 chalk
.description(chalk.cyan('创建新项目'))
.option('-f, --force', chalk.red('如果目录已存在将覆盖原目录,请谨慎使用,这会先删除你已存在的项目再进行创建,可能会存在意外情况'))
.action(这里是一个回调函数,执行这个命令的操作);
command 监听命令,description 设置命令的描述,option 是设置命令的可选参数,比如这里设置了 --force
,意思就是是否强制覆盖目录,这个参数是可选的。 所以 --help
, --version
其实也是参数。 action 就是这个命令真正的执行的方法,这里我们定义了一个 create
函数,该方法最后就会执行这个函数,在这个函数中我们去执行一系列的包括询问用户、添加 loading、下载模板等操作。
program.parse(process.argv);
是固定写法,在最后执行,就是将 process.argv 传递给 parse,parse 会解析用户在终端输入的一切命令还有参数,并进行执行。
来看完整版:
const runner = () => {
program.name(chalk.cyan('demo')).usage(`${chalk.yellow('<command>')} [options]`);
program.version(
`\r\n ${chalk.cyan.bold(VERSION)}
${chalk.cyan.bold(BRAND_LOGO)}`
);
program
.command('create <project-name>') // 这里不能使用 chalk
.description(chalk.cyan('创建新项目'))
.option('-f, --force', chalk.red('如果目录已存在将覆盖原目录,请谨慎使用,这会先删除你已存在的项目再进行创建,可能会存在意外情况'))
.action(() => {
// 执行操作
});
program.on("--help", function () {
console.log(`\r\n终端执行 ${chalk.cyan.bold("xwg <command> --help")} 获取更多命令详情\r\n`);
});
program.parse(process.argv);
};
inquirer
具体配置参见官方文档:www.npmjs.com/package/inq…[4]
贴太多代码影响阅读体验,想看具体实现细节和类型定义的可以去看源码。
import Inquirer from 'inquirer';
export const prompt = async (prompts: any[]) => {
return await new Inquirer.prompt(prompts);
};
/** 询问要创建的项目类型 */
export const askCreateType = async () => {
const { projectType } = await prompt([
// 返回值为 Promise
// 具体配置参见:https://www.npmjs.com/package/inquirer#questions
{
type: "list",
name: "projectType",
message: "请选择你要创建的项目类型",
choices: [
{ name: "vue", value: 'vue' },
{ name: "react", value: 'react' },
{ name: "uniapp", value: 'uniapp' },
{ name: "koa", value: 'koa' },
// { name: "nest", value: 'nest' },
{ name: "library", value: 'library' },
],
},
]);
return projectType;
};
ora
import ora from "ora";
import chalk from "chalk";
const spinner = ora('loading');
spinner.start(); // 开启加载
spinner.succeed('成功');
spinner.fail("请求失败,正在重试...");
download-git-repo
在网上看到其他文章说该库不支持 Promise,需要 util 模块 promisify 一下:
import util from 'node:util';
import download from 'download-git-repo';
export const downloadGitRepo = util.promisify(download);
// 下面是伪代码
downloadGitRepo(`direct: 仓库地址`, 存放的目标地址, { clone: true })
一些前置知识
node_modules 的 bin 目录
我们下载的一些 cli 工具,会放在 node_modules
下一个 .bin
的目录中。虽然其文件没有后缀,但其实就是 js 文件,它会去找对应的代码执行,下面是 bin 目录下 nodemon 命令的文件内容:
当我们执行命令时 node 会从这个目录中找到我们要执行的命令。那为什么这些包会在这里生成一个命令文件呢?主要是因为可以在 package.json
中配置字段 bin
:
我们这里配置一个命令 xwg
,它在执行时会去寻找 bin
文件夹下的 cli.js
文件进行执行。这也就是为什么我的包名是 xwg-cli
,但是我却可以通过 xwg
来执行命令。我们还可以创建多个,不同的命令对应不同的执行文件:
"bin": {
"xwg": "./bin/cli.js",
"ikun": "./bin/ikun.js"
},
必要的注释
接下来也是一个非常重要的点:
image.png可以看到,在 cli.js
的首行,有一行注释:#! /usr/bin/env node
。这个注释可不是多余,所有的 node cli 必须在开头包含此注释,否则命令就没法正常运行,这句注释就是指该命令在 node 环境下运行,所以请务必不要省略此注释!
本地测试
我们可以现在 bin/cli.js
下随便写点什么:
#! /usr/bin/env node
console.log('跑起来了')
基于当前项目打开你的终端,运行 npm link,链接你的本地包,此时我们终端运行 xwg
试试:
如果没有权限请记得给你的项目赋予管理员权限。
工程化配置tsconfig.json
noEmit
必须是 false,否则 tsc 编译后不输出文件。
include
必须包含 types/**/*
,否则在 types 下的声明不起效果。
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"sourceMap": false,
"resolveJsonModule": true,
"isolatedModules": false,
"esModuleInterop": true,
"noEmit": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true
},
"include": ["src", "types/**/*"]
}
nodemon
由于每次更改都要重新执行一遍 node ./lib/index.js
过于麻烦,所以需要借助于 nodemon。
下载 nodemon:yarn add nodemon \-D
。
新建 nodemon.json
:
{
"watch": ["src"], // 监听 src 目录
"ext": "ts", // 匹配扩展名为 ts 的文件
"exec": "rimraf lib && tsc --outDir lib --module CommonJS"
}
watch
:监听 src 目录。
ext
:匹配扩展名为 ts 的文件。
exec
:rimraf 是一个 npm 包,作用是删除目录文件,优点是跨平台兼容性好。该命令的意义是:tsc 编译 src 下的 ts 文件并制定输出目录为 lib (--outDir lib
),并指定编译目标的模块化规范是 commonjs(--module CommonJS
)。
因为有 nodemon.json
的配置文件,我们在 package.json
中可以简写:
"scripts": {
"dev": "nodemon",
},
每次我们执行 npm run dev
实际上就是执行 nodemon,nodemon 会寻找 nodemon.json
并执行里面的配置,每次当我们修改 src 的 ts 文件它就会删除 lib 并帮助我们重新 tsc 编译一遍。
- bin 文件我们存放命令的执行入口
- lib 的代码是 src 经过 tsc 编译为 commonjs 的代码
- src 是我们编写核心代码的地方
- test 用于存放单元测试
- types 用于存放 ts 类型声明
- .editorconfig 编辑器配置
- .eslintignore eslint 忽略文件
- .eslintrc eslint 配置文件
- commitlin.config.js commit 提交信息校验的配置文件,commit message 不对会报错
- nodemon.json 配置 nodemon,帮助我们保存代码后热更新,不用再执行
node lib/index.js
- tsconfig.json ts 配置文件
bin/cli.js
#! /usr/bin/env node
/**
* xwg cli
* @author xiwenge <1825744594@qq.com>
* @create 2023/06/24
*/
'use strict';
const runner = require('../lib/index').default;
runner();
不建议把大量的代码堆在 bin 目录下,可能很多教程会这么做,但是这样拆分代码不太简洁美观。 我们将所有核心代码放入 lib 目录下,从 lib 引入核心代码来进行运行。
在这里我们引入 runner 函数进行执行。
src/index.ts
import runner from './runner';
export default runner;
src/runner.ts
这里,我们把 command 相关的代码拆出去。
import { program } from 'commander';
import chalk from 'chalk';
import {
create
} from './commands';
import { BRAND_LOGO, VERSION } from './const';
const runner = () => {
program.name(chalk.cyan('xwg')).usage(`${chalk.yellow('<command>')} [options]`);
program.version(
`\r\n ${chalk.cyan.bold(VERSION)}
${chalk.cyan.bold(BRAND_LOGO)}`
);
create();
program.on("--help", function () {
console.log(`\r\n终端执行 ${chalk.cyan.bold("xwg <command> --help")} 获取更多命令详情\r\n`);
});
program.parse(process.argv);
};
export default runner;
src/const.ts
此目录用于存放我们需要使用到的静态变量。这种艺术字可以访问 ascii 艺术字[5],我们这里使用的字体是 ANSI Shadow
。
/**
* 静态变量
* @author xiwenge <1825744594@qq.com>
* @create 2023/06/25
*/
import fs from 'fs-extra';
import path from 'node:path';
import util from 'node:util';
import download from 'download-git-repo';
/** 当前根目录 */
export const ROOT_DIR = path.resolve(__dirname, '../');
const { version } = fs.readJSONSync(path.resolve(ROOT_DIR, 'package.json'));
/** https://tooltt.com/art-ascii/ font: ANSI Shadow */
export const BRAND_LOGO = `
██╗ ██╗██╗ ██╗ ██████╗ ██████╗██╗ ██╗
╚██╗██╔╝██║ ██║██╔════╝ ██╔════╝██║ ██║
╚███╔╝ ██║ █╗ ██║██║ ███╗ ██║ ██║ ██║
██╔██╗ ██║███╗██║██║ ██║ ██║ ██║ ██║
██╔╝ ██╗╚███╔███╔╝╚██████╔╝ ╚██████╗███████╗██║
╚═╝ ╚═╝ ╚══╝╚══╝ ╚═════╝ ╚═════╝╚══════╝╚═╝
`;
/** 当前版本号 */
export const VERSION = version;
export const getRepoURL = (tag: string) => {
return `https://gitee.com/redstone-1/${tag}.git`;
};
export const downloadGitRepo = util.promisify(download);
封装loading和prompt
loading
import ora from "ora";
import chalk from "chalk";
import { LoadingOther } from '../types';
/**
* 睡觉函数
* @param {Number} delay 睡眠时间
*/
const sleep = (delay: number) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(false);
}, delay);
});
};
/**
* 加载中方法
* @param message - 提示信息
* @param callback - 执行的回调
* @param projectName - 项目名
* @returns
*/
export const loading = async (message: string, callback: () => any, other: LoadingOther): Promise<any> => {
const spinner = ora(message);
spinner.start(); // 开启加载
try {
const res = await callback();
// 加载成功
spinner.succeed(
`${chalk.black.bold('下载成功!执行以下命令打开并运行项目:')}
\r\n ${chalk.gray.bold(`cd ${other?.projectName}`)}
\r\n ${chalk.gray.bold('npm install')}
\r\n ${chalk.gray.bold('npm run dev')}
\r\n ${chalk.gray.bold('问题、意见、建议请反馈至:https://github.com/Redstone-1/xwg-cli/issues')}
`
);
return res;
} catch (error) {
// 加载失败
spinner.fail("请求失败,正在重试...");
await sleep(1000);
// 重新拉取
return loading(message, callback, other);
}
};
prompt
import Inquirer from 'inquirer';
export default async (prompts: any[]) => {
return await new Inquirer.prompt(prompts);
};
askUser.ts
将所有询问用户的操作都封装在此,这里仅贴出示例:
import prompt from "../../utils/prompt";
// ========== library ==========
/** 询问要创建的项目类型 */
export const askCreateType = async () => {
const { projectType } = await prompt([
// 返回值为 Promise
// 具体配置参见:https://www.npmjs.com/package/inquirer#questions
{
type: "list",
name: "projectType",
message: "请选择你要创建的项目类型",
choices: [
{ name: "vue", value: 'vue' },
{ name: "react", value: 'react' },
{ name: "uniapp", value: 'uniapp' },
{ name: "koa", value: 'koa' },
// { name: "nest", value: 'nest' },
{ name: "library", value: 'library' },
],
},
]);
return projectType;
};
...
create 命令
新建 commands 目录存放各类命令
image.png拆分 action
将 action 的回调函数 create 拆分出去:
image.pngcreate.ts
这里主要做了一件事,判断要创建的项目目录名是否存在,分别走入不同的分支逻辑:
import path from 'path';
import fs from 'fs-extra';
import dirExistCall from './dirExistCall';
import downloadRepo from './downloadRepo';
/**
* 创建新项目
* @param projectName - 项目名
* @param options - 命令参数
*/
export default async (projectName: string, options: any) => {
// 获取当前工作目录
const cwd = process.cwd();
// 拼接得到项目目录
const targetDirectory = path.join(cwd, projectName);
// 判断目录是否存在
if (fs.existsSync(targetDirectory)) {
await dirExistCall(options, projectName, targetDirectory);
} else {
await downloadRepo(projectName, targetDirectory);
}
};
dirExistCall.ts
当目录已经存在时,需要询问用户是否覆盖。
import fs from "fs-extra";
import downloadRepo from './downloadRepo';
import { askOverwrite } from './askUser';
/**
* 如果目录已经存在时调用
* @param options - 命令参数
* @param targetDirectory - 目标路径
*/
export default async (options: any, projectName: string, targetDirectory: string) => {
// 判断是否使用 --force 参数
if (options.force) {
// 删除重名目录
await fs.remove(targetDirectory);
await downloadRepo(projectName, targetDirectory);
} else {
const { isOverwrite } = await askOverwrite();
// 选择 Overwirte
if (isOverwrite) {
// 先删除掉原有重名目录
await fs.remove(targetDirectory);
await downloadRepo(projectName, targetDirectory);
}
}
};
downloadRepo.ts
当用户将所有的选择都确认后执行下载模板的操作。下面给出部分代码示例,请从下往上阅读:
import {
askCreateType,
askNeedTypeScript,
askIsAgreeCli,
askVueVersion,
askNeedUviewUI,
} from "./askUser";
import { loading } from "../../utils/loading";
import { getRepoURL, downloadGitRepo } from "../../const";
import { TProjectType } from '../../types';
import chalk from "chalk";
/**
* 下载 vue 模板
* @param projectName - 项目名称
* @param targetDirectory - 目标存储路径
*/
const downloadVueTemplate = async (projectName: string, targetDirectory: string) => {
let repoURL = '';
const needTypeScript = await askNeedTypeScript();
if (needTypeScript) {
repoURL = getRepoURL('vue-template-typescript');
}
if (!needTypeScript) {
repoURL = getRepoURL('vue-template');
}
await loading('正在下载模板,请稍后...', () => downloadGitRepo(`direct:${repoURL}`, targetDirectory, { clone: true }), { projectName });
};
/**
* 执行创建命令
* @param projectType - 项目类型 "library" | "react" | "vue" | "uniapp" | "koa" | nest""
* @param projectName - 项目名称
* @param targetDirectory - 目标存储路径
*/
const execCreate = async (projectType: TProjectType, projectName: string, targetDirectory: string) => {
switch (projectType) {
case 'library':
await downloadLibraryTemplate(projectName, targetDirectory);
break;
case 'vue':
await downloadVueTemplate(projectName, targetDirectory);
break;
case 'react':
await downloadReactTemplate(projectName, targetDirectory);
break;
case 'uniapp':
await downloadUniappTemplate(projectName, targetDirectory);
break;
case 'koa':
await downloadKoaTemplate(projectName, targetDirectory);
break;
case 'nest':
console.log(chalk.gray.bold(`\r\n 开发中,敬请期待...\r\n`));
break;
}
};
/**
* 创建项目
* @param projectName - 项目名称
* @param targetDirectory - 目标存储路径
*/
export default async (projectName: string, targetDirectory: string) => {
console.log(chalk.red.bold(`\r\n 请注意:本 cli 下大部分模板采用 vite 构建,node 版本需要 14.18+ 或 16+ 或更高\r\n`));
const projectType = await askCreateType();
await execCreate(projectType, projectName, targetDirectory);
};
到这里我们就完成了 create 命令,现在执行 xwg create xxx
就可以创建项目了。
贴了太多代码看起来可能不是很顺畅,建议直接去看源码,更能快速上手学习。
在其他的一些高级 cli 教程里,使用 babel 进行各种配置文件的生成和写入,这是更高级的内容,暂时做不了。目前比较呆的处理方式是写一堆模板,不同的配置下载不同的模板。引入 babel 可以减少模板仓库数量,根据用户的选择动态的删除、插入各种文件,具备更强大更灵活的处理能力,但是目前不具备 babel 的运用能力,后续再考虑进行升级。若有愿意共建的朋友,欢迎 Pr。
关于本文
https://juejin.cn/post/7256702654579310652