Skip to content
目录

从0搭建一个命令行CLI工具

背景

大多数人应该都面临过一个问题,每次项目都使用类似create-react-app这类工具,然后还要再根据自己的需求进行配置,添加各种依赖包。

又比如我会更加青睐于Vite,所以我更希望默认打包工具是Vite而不是WebpackVite官方提供的脚手架会有和上面一样的问题,当我想要去自己做一个cli时却发现无从下手,本篇文章就记录一下我做的一个交互式命令行cli过程

本文实现了一个基于Node.js的命令行CLI工具

目前常见的cli方案有很多,比如:

degit方案

Yeoman方案

……

这里就不一一介绍了,感兴趣可以自行了解

degit应该是最简单的方案了,他的问题是需要手动执行命令,而我的需求是一条命令生成可以运行的项目

准备工作

确保已经安装Node.js(本文中使用的为v18.16.0

核心库:

在开始前我想要先介绍一下主要用到的几个核心npm

  1. Commander.js

    主要用于获取命令行参数解析,基于Node.jsprocess模块

  2. Inquirer.js

    交互式命令行信息收集器,基于Node.jsreadline模块

  3. execa

    主要用于执行外部命令,基于Node.jschild_process模块


这么描述不太清晰,我举个例子解释一下

假设我们生成脚手架有以下核心步骤:

步骤命令行示例使用的库
启动脚手架create-spr-app startCommander.js
交互获取项目信息您的项目名称:______Inquirer.js
安装依赖pnpm installexeca

主要流程:

我们先预先制定好脚手架的流程

大致如下:

步骤描述
1启动脚手架
2选择包管理器
3选择模板
4选择打包工具
5选择默认依赖
6项目生成

自动化流程:

工欲善其事,必先利其器

对于一个脚手架,我会希望他能够发布到npm

更理想的是我本地推到GitHub仓库后自动发布,这里我选择了Github Action

配置文件如下:

yaml
# release.yml
name: Release

on:
  push:
    branches:
      - main

defaults:
  run:
    shell: bash

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      # 设置 pnpm 指定版本7.0
      - name: Setup PNPM
        uses: pnpm/action-setup@v2.2.1
        with:
          version: ^7.0
      # 设置 Node
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: 16
          cache: "pnpm"
      # 安装依赖
      - name: Install dependencies
        run: pnpm install
      # 打包
      - name: Build Packages
        run: pnpm run build
      # 创建.npmrc文件
      - name: Create .npmrc file
        run: echo "//registry.npmjs.org/:_authToken=${{secrets.NPM_AUTH_TOKEN}}" > ~/.npmrc
      # 发布
      - name: Publish to NPM
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
        run: npm publish --access public
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

这里我动态生成了一个.npmrc文件,是因为我在发布时的环境变量NODE_AUTH_TOKEN出现问题失效了,所以动态创建一个.npmrc解决了这个问题


NPM_AUTH_TOKENnpm (npmjs.com)登录后从Access Tokens获取

然后在GitHub仓库中settings/Security/Secrets and variables/Actions创建环境变量NPM_AUTH_TOKEN

屏幕截图 2023-06-03 172210

具体我就不在这细说了,实在不会可以百度

总之,我们通过Github Action来完成自动发布这个工作

万事具备我们准备启程!

初始化项目

这个cli项目我打算采用Node.js+TypeScript+ESM,因为是ESM所以会有一些不同

接下来给出核心配置文件,会包含一些核心注释


package.json:

json
{
  "name": "create-spr-app",
  "version": "1.1.6",
  "author": "PassionFruit",
  "license": "MIT",
  "description": "a interactive javascript cli by node",
  "main": "./dist/index.js",
  "type": "module",
  "keywords": [
    "create-spr-app",
    "cli",
    "react",
    "vue"
  ],
  "files": [
    "dist/*",
    "template/.gitignore",
    "template/*"
  ],
  "repository": {
    "type": "git",
    "url": "https://github.com/PassionFruitAXE/create-spr-app.git"
  },
  "bugs": {
    "url": "https://github.com/PassionFruitAXE/create-spr-app/issues"
  },
    // 这个很重要 它决定了你将用什么全局命令来启动脚手架
  "bin": {
    "create-spr-app": "./dist/index.js"
  },
  "scripts": {
      // 开发环境调试我是用的ts-node 需要全局安装ts-node
    "dev": "ts-node src/index.ts start",
      // 打包用的tsc
    "build": "npx rimraf dist && npx tsc",
    "serve": "create-spr-app start"
  },
  "dependencies": {
    "chalk": "^5.2.0",
    "commander": "^10.0.1",
    "execa": "^7.1.1",
    "inquirer": "^9.2.2"
  },
  "devDependencies": {
    "@types/inquirer": "^9.0.3",
    "@types/node": "^20.1.0",
    "rimraf": "^5.0.0",
    "typescript": "^5.0.4"
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

tsconfig.json:

json
{
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "module": "NodeNext",
    "forceConsistentCasingInFileNames": true,
    "sourceMap": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*.ts", "src/index.ts"],
  "exclude": ["node_modules/*", "template/*.tsx"],
  "ts-node": {
    "esm": true
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

安装依赖:

bash
$ pnpm install
1

代码入口是src/index.ts

项目需求获取

ts
// src/index.ts
import { program } from "commander";

async function action(){
    // 执行操作
}

program.version("1.0.0");
program.command("start").description("启动cli").action(action);
program.parse(process.argv);
1
2
3
4
5
6
7
8
9
10

像这样就是Commander.js的简单调用了,接受命令为:create-spr-app start

即使没有start参数,也会得到相应的提示和引导,这得益于Commander.js提供的封装


接下来将我们通过Inquirer.js实现step2-step5

ts
// src/index.ts
async function action(){
  /** 获取项目名 包管理器 模板 打包工具 */
  const { projectName, packageManager, template, builder } =
    await inquirer.prompt([
      {
        type: "input",
        name: "projectName",
        message: "输入项目名称",
      },
      {
        type: "list",
        name: "packageManager",
        message: "选择包管理器",
          // PackageManager为枚举类型
        choices: [PackageManager.PNPM, PackageManager.YARN, PackageManager.NPM],
      },
      {
        type: "list",
        name: "template",
        message: "选择模板",
          // Template为枚举类型
        choices: [Template.REACT],
      },
      {
        type: "list",
        name: "builder",
        message: "选择打包工具",
          // Builder为枚举类型
        choices: [Builder.VITE],
      },
    ]);

  /** 获取默认依赖包 */
  const { deps } = await inquirer.prompt([
    {
      type: "checkbox",
      name: "deps",
      message: "默认依赖包",
      choices: createPackagesByTemplate(template),
    },
  ]);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

Inquirer.js简单来说就是通过命令行交互选择的choices值赋值给对应的name属性,例如在获取默认依赖包中赋值给了deps属性

这个choices可以是任何类型

这里采用了一个工厂函数的原因也很简单,根据不同的模板返回不同的依赖包列表,并且这里先按下依赖包choices类型不表

至此,我们可以认为已经获取到了用户对这个项目的所有需求,接下来就是生成项目

生成项目

在上一个阶段我们已经获取了用户对生成项目的所有需求,下一个步骤是生成项目文件

ts
async function action(){
	······
  /** 创建项目目录 */
  const rootPath = path.join(process.cwd(), `/${projectName}`);

  /** 创建项目实例 */
  const project = new Project({
    rootPath,
    projectName,
    packageManager,
    template,
    builder,
    deps,
  });
  /** 生成项目 */
  await project.run();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

上方代码在Project对象中传入项目的所有参数,并且执行project.run()方法

所以Project类的实现是整个脚手架中的核心

我为一个前端项目划分了如下几个模块

模块名对应值
package.json模块依赖相关
构建工具模块打包工具相关
ts模块ts相关
文件模块其他文件相关
git模块git相关
README.md模块README.md相关

其实到这儿为止一个简单的脚手架雏形已经构建完毕了,后面利用fs模块生成文件即可,方案也并非唯一

最后安装依赖即可,execa基本使用如下:

ts
// utils/command.ts
async function useCommand(command: string, cwd: string) {
  await execa(`${command}`, [], {
    cwd,
    stdio: ["inherit", "pipe", "inherit"],
  });
}

// packageModule.ts
async function packageInstall(): Promise<void> {
    console.log(chalk.cyan("安装依赖中~~~"));
	// Project类实例化时传递了packageManager和rootPath
    await useCommand(
        `${this.config.packageManager} install`,
        this.config.rootPath
    );
    console.log(chalk.cyan("依赖安装完成"));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

如果你有兴趣可以去PassionFruitAXE/create-spr-app查看具体源代码,目前这个项目还有很多缺陷,这里我就不讲源码设计过程了,接下来主要讲几个遇到的问题

遇到的问题

问题1:

报错export not definerequire() not support

这个报错主要是因为依赖中仍采用的CJS代码,而这个项目我采用的为ESM

解决方法:

  1. 直接全换CJS,降低部分依赖版本
  2. package.json设置type: moduletsconfig设置module: NodeNext、更新依赖为最新版本,目前核心的三个库都有ESM版本

问题2:

脚手架中我默认将prettier eslint stylelint等依赖加入了,然而引入一些新的包会需要修改一些配置文件(比如tailwindcss

解决方法:

其实也没太好的方法,为每种组合单独实现一个类,利用面向对象方法的继承+多态实现,再辅以工厂模式+策略模式

实际上我几乎所有模块都这么做的,我暂时寄希望于不会有太多”交叉“的依赖配置文件

ts
// project.ts
/**
   * Project类构造函数
   * @param config 项目配置对象
   */
constructor(public config: TConfig) {
    this.gitModule = new GitModule(config);
    this.readmeModule = new ReadmeModule(config);
    this.fileModule = createFileModule(config);
    this.tsModule = createTSModule(config);
    this.builder = createBuilder(config);
    this.packageJsonModule = createPackageJsonModule(config);
    /** 添加构建工具到依赖中 */
    this.packageJsonModule.addDependencies(this.builder.value);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ts
// packageModule.ts
class reactPackageJsonModule extends PackageJsonModule {
  constructor(config: TConfig) {
    super(config);
    const {
      react,
      reactDom,
      reactRouterDom,
      typesNode,
      typesReact,
      typesReactDom,
      eslintPluginReact,
    } = globalDependencies;
    this.mergeConfig({
      dependencies: {
        ...react,
        ...reactDom,
        ...reactRouterDom,
      },
      devDependencies: {
        ...typesNode,
        ...typesReact,
        ...typesReactDom,
        ...eslintPluginReact,
      },
    });
  }
}

export function createPackageJsonModule(config: TConfig) {
  if (config.template === Template.REACT) {
    return new reactPackageJsonModule(config);
  } else {
    throw new CommanderError(500, "500", `${config.template}对应的依赖模板`);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

问题3:

有一些依赖可能需要执行一些特别的操作

比如vite这类可能需要在package.json中添加一些script,并且在Project类实例中是维护了一个package.json配置对象的,我并不希望生成配置文件后在对他进行修改,最好是在生成package.json文件前对属性进行修改

又比如prettier绑定Git hook,需要在项目生成结束后执行

解决方法:

还记得上面有说inquirer.js交互时按下依赖包choices类型不表吗,我设计的choices类型如下

ts
export type TDependence = {
  dependencies?: Record<string, string>;
  devDependencies?: Record<string, string>;
  beforeInitCallback?: (project: Project) => void;
  afterInitCallback?: (project: Project) => void;
};
1
2
3
4
5
6

依赖项存在两个生命周期的回调函数

beforeInitCallback会在项目文件生成前执行,afterInitCallback会在项目文件生成后执行

修改package.jsonscript字段通常在beforeInitCallback执行

绑定Git hook通常在afterInitCallback执行

示例如下:(打包工具是一个特殊的package

ts
class ViteBuilderForReact extends ViteBuilder {
  constructor() {
    super();
    const newValue: TDependence = {
      devDependencies: {
        "@vitejs/plugin-react": "^4.0.0",
      },
      beforeInitCallback: (project: Project) => {
        project.packageJsonModule?.mergeConfig({
          scripts: {
            build: "vite build",
            dev: "vite",
            preview: "vite preview",
            commit: "git-cz",
            prepare: "husky install",
            lint: "npm run lint:script && npm run lint:style",
            "lint:script": "eslint --ext .js,.jsx,.ts,.tsx --fix --quiet ./",
            "lint:style": 'stylelint --fix "src/**/*.{css,scss}"',
          },
        });
      },
      afterInitCallback: (project: Project) => {
        fs.copyFileSync(
          path.join(__dirname, REACT_VITE_PREFIX, "/vite.config.ts"),
          path.join(project.config.rootPath, "/vite.config.ts")
        );
      },
    };
    this.value = mergeObject<TDependence>(this.value, newValue);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

其实我们可以做的更好,比如借鉴vite,webpack这类库暴露一系列生命周期函数,这些就是后话了


问题4:

如何维护安装依赖的版本

解决方法:

这个目前为止我也没想到什么好办法,因为要考虑的太多了

依赖之间的版本依赖,不同的版本还会有一些API废弃等,所以目前还是人工维护依赖,希望之后能找到更好的方法

后续更新需求:

开箱即用模板,基于配置文件修改内容的degit+execa方案,修改配置文件即可实现修改

Released under the MIT License.