Cli脚手架搭建

102 阅读7分钟

为什么要设计

  • 减少重复性工作,无需从零开始构建项目或者手动复制粘贴项目代码;
  • 有利用多人协作开发,统一团队代码规范、开发目录结构等;
  • 可以集成多套项目模版,根据选择使用;
  • 可以通过动态交互生成项目结构和配置;
  • 针对业务或者复杂的场景痛点,可以通过集成脚本的形式实现。

满足功能

基础功能

  • 命令行输入,初始化项目,渲染模版

拓展功能

  • 动态交互生成模版
  • 集成多套项目模版
  • 集成脚本,实现业务&场景处理

DEMO

1. npm init

生成package.json

2. package.json 添加软链接

// package.json
{
    "bin": { 
      "cj-cli": "./bin/index.js"
    }
}

npm link 可以链接到全局,全局使用 cj-cli 命令

3. 编写index.js ,写一个命令行工具

#! /usr/bin/env  node

console.log('hello world')

#! 符号的名称叫 Shebang,用于指定脚本的解释程序

依赖基础包

  • commander

命令行工具,读取命令行,解析参数

  • inquirer

交互式命令行工具

  • chalk

颜色插件,可修改命令行输出文字的样式,区分info,warn,error等日志

  • ora

显示加载中loading效果,

  • fs-extra

node fs文件系统模块加强版

  • pacote

获取node最新版本等信息

  • handlebars

高效构建语义化模版

实战

1. 参考demo先搭建好架子

目录结构:

2. 安装相关依赖

package.json

  "devDependencies": {
    "@rollup/plugin-json": "^5.0.1",
    "@rollup/plugin-terser": "^0.1.0",
    "eslint": "^7.32.0 || ^8.2.0",
    "eslint-config-airbnb-base": "^15.0.0",
    "eslint-config-prettier": "^8.5.0",
    "eslint-plugin-import": "^2.25.2",
    "eslint-plugin-prettier": "^4.2.1",
    "prettier": "^2.7.1",
    "rollup": "^3.3.0"
  },
  "dependencies": {
    "chalk": "^4.1.2",
    "child_process": "^1.0.2",
    "commander": "^9.4.1",
    "download-git-repo": "^3.0.2",
    "fs-extra": "^10.1.0",
    "handlebars": "^4.7.7",
    "inquirer": "^8.0.0",
    "ora": "^5.4.1",
    "semver": "^7.3.8"
  }

3. 配置eslint,prettier,规范,格式化代码;

.eslintrc.js

module.exports = {
  // 停止在父级目录中寻找配置
  root: true,

  // 配置多个环境
  env: {
    commonjs: true,
    es2021: true,
    node: true,
  },

  // 让prettier的解析作为eslint的一部分
  extends: ['airbnb-base', 'prettier'],
  parserOptions: {
    // 支持最新的ECMASript语法
    ecmaVersion: 'latest',
  },

  // 关闭和eslint中以及和其他拓展中有冲突的规则
  plugins: ['prettier'],
  rules: {
    'prettier/prettier': 'error',
    'no-console': 'off',
    'global-require': 'off',
    'import/no-dynamic-require': 'off',
    'import/no-import-module-exports': 'off',
  },
}

.prettierrc

{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "printWidth": 100,
  "useTabs": false
}

使用VsCode的话,开启eslint和prettier相关插件,配合使用

4. 编写工具函数

scripts/log.js

const chalk = require('chalk')

const yellow = (msg) => console.log(chalk.yellow(msg))

const red = (msg) => console.log(chalk.red(msg))

const green = (msg) => console.log(chalk.green(msg))

export default {
  yellow,
  red,
  green,
}

5. 设置node版本检查。如果不满足版本要求,提示升级版本;

scripts/checkVersion.js

import log from './log'

const semver = require('semver')
const pkg = require('../package.json')

const requiredVersion = pkg.engines.node

// 检查node版本
export default () => {
  const ok = semver.satisfies(process.version, requiredVersion, { includePrerelease: true })

  if (!ok) {
    log.red(
      `你当前使用的Node版本 ${process.version}, 但脚手架需要 Node版本 ${requiredVersion}.\n请升级你的Node版本`
    )

    process.exit(-1)
  }
}

bin/index.js

import checkVersion from '../scripts/checkVersion'

// 检查node版本
checkVersion()

6. 配置基础命令,输出cli的版本号,help提示等

bin/index.js

+ const { program } = require('commander')
import checkVersion from '../scripts/checkVersion'
+ const pkg = require('../package.json')

// 检查node版本
checkVersion()

+ /**
+  * 默认 -V输出版本信息
+  * 可以通过重写的信息,-v 输出包名和版本号信息
+  */
+ program.version(`${pkg.version}`, '-v', '--version').usage('<command> [options]')


+ // 解析命令行
+ program.parse(process.argv)

+ // 没有参数时,输出帮助信息
+ if (!process.argv.slice(2).length) {
+   program.outputHelp()
+ }

7.编写项目模版

暂时跳过

8. commands命令,创建模版

bin/index.js

const { program } = require('commander')

const checkVersion = require('../scripts/checkVersion')
+ import commandsOptions from '../commands/index'

const pkg = require('../package.json')

// 检查node版本
checkVersion()

+ // 遍历commands命令
+ Object.values(commandsOptions).forEach((item) => {
+   const { command, description, action } = item
+   program.command(command).description(description).action(action)
+ })

/**
 * 默认 -V输出版本信息
 * 可以通过重写的信息,-v 输出包名和版本号信息
 */
program.version(`${pkg.version}`, '-v', '--version').usage('<command> [options]')

// 配置命令

// 解析命令行
program.parse(process.argv)

// 没有参数时,输出帮助信息
if (!process.argv.slice(2).length) {
  program.outputHelp()
}

commands/create.js

import logger from '../scripts/log'

const fs = require('fs-extra')
const inquirer = require('inquirer')
const { execFileSync } = require('child_process')
const download = require('download-git-repo')
const handlebars = require('handlebars')
const ora = require('ora')

let spinner

export default {
  command: 'create <project-name>',
  description: '初始化项目',
  action: async (projectName) => {
    const isExist = fs.existsSync(projectName)

    // 已存在同名
    if (isExist) {
      const { override } = await inquirer.prompt([
        {
          type: 'confirm',
          name: 'override',
          message: '当前目录中已经存在同名的文件夹/项目。是否覆盖?',
          default: false,
        },
      ])
      if (override) {
        fs.removeSync(projectName)
      } else {
        logger.red('停止创建项目。')
        process.exit(-1)
      }
    }

    const gitAuthor = execFileSync('git', ['config', 'user.name'], { encoding: 'utf-8' })

    const { description, author } = await inquirer.prompt([
      {
        type: 'input',
        name: 'description',
        message: '项目描述',
      },
      {
        type: 'input',
        name: 'author',
        message: '你的名字',
        default: gitAuthor.trim(),
      },
    ])

    spinner = ora('拉取远程模版中 loading...').start()
    download('jayyoonn/cj-cli-template', projectName, (err) => {
      if (err) {
        spinner.fail(`拉取模版失败${err}`)
        return
      }
      const packagePath = `${projectName}/package.json`
      const packageContent = fs.readFileSync(packagePath, 'utf-8')

      // 使用handlebars解析模板引擎
      const packageResult = handlebars.compile(packageContent)({
        description,
        author,
        name: projectName,
      })

      // 将解析后的结果重写到package.json文件中
      fs.writeFileSync(packagePath, packageResult)
      spinner.succeed('创建项目成功!!')
    })
  },
}

9. 添加changelog

 "scripts": {
    "test": "echo "Error: no test specified" && exit 1",
    "lint": "eslint . --fix",
    "build": "rm -rf dist && rollup -c",
+   "release": "standard-version"
  },
 "devDependencies": {
    "@rollup/plugin-json": "^5.0.1",
    "@rollup/plugin-terser": "^0.1.0",
    "eslint": "^7.32.0 || ^8.2.0",
    "eslint-config-airbnb-base": "^15.0.0",
    "eslint-config-prettier": "^8.5.0",
    "eslint-plugin-import": "^2.25.2",
    "eslint-plugin-prettier": "^4.2.1",
    "prettier": "^2.7.1",
    "rollup": "^3.3.0",
+   "standard-version": "^9.5.0" 
  },

首次执行,为您的第一个版本生成变更日志,只需执行以下操作:

pnpm release -- --first-release

10. rollup配置打包

rollup.config.js

const json = require('@rollup/plugin-json')
const teser = require('@rollup/plugin-terser')

module.exports = {
  input: './bin/index.js',
  output: {
    format: 'cjs',
    banner: '#!/usr/bin/env node',
    file: './dist/index.js',
  },
  plugins: [teser(), json()],
}

11. npm发包

  1. npm adduser(登陆npm)
  2. npm publish

访问链接

进阶&补充

commonJs + js -> ESModule + TypeScript
添加husky
git-cz 提交

1. tsconfig.json生成

{
  "compilerOptions": {
    "target": "es2017",
    "module": "ESNext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "esModuleInterop": true,
     "forceConsistentCasingInFileNames": true,
    "strict": true,
        "skipLibCheck": true
  },
  "include": ["./bin", "./scripts", "@types"]
}

2. package.json修改,依赖版本调整

{
  "name": "@jayyoonn/cj-cli",
  "version": "2.3.1",
  "description": "cj-cli脚手架工具",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "lint": "eslint . --fix"
    "build": "rollup -c"
  },
  "bin": {
    "cj-cli": "./dist/index.js"
  },
  "keywords": [
    "cj-cli"
  ],
  "author": "jayyoonn",
  "license": "ISC",
  "homepage": "https://github.com/jayyoonn/cj-cli",
  "repository": "https://github.com/jayyoonn/cj-cli",
  "bugs": "https://github.com/jayyoonn/cj-cli/issues",
  "engines": {
    "node": "^16.3.0"
  },
  "devDependencies": {
    "@rollup/plugin-commonjs": "^25.0.2",
    "@rollup/plugin-json": "^5.0.2",
    "@rollup/plugin-node-resolve": "^15.1.0",
    "@rollup/plugin-terser": "^0.1.0",
    "@types/inquirer": "^9.0.3",
    "@typescript-eslint/eslint-plugin": "^5.61.0",
    "@typescript-eslint/parser": "^5.61.0",
    "cz-git": "^1.6.1",
    "eslint": "^8.44.0",
    "eslint-config-airbnb-base": "^15.0.0",
    "eslint-config-prettier": "^8.8.0",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-prettier": "^4.2.1",
    "husky": "^8.0.0",
    "load-json-file": "^7.0.1",
    "prettier": "^2.8.8",
    "rollup": "^3.26.1",
    "standard-version": "^9.5.0",
    "tslib": "^2.6.0",
    "typescript": "^5.1.6",
    "@rollup/plugin-typescript": "^11.1.2",
    "@types/node": "^20.3.2",
    "@types/semver": "^7.5.0"
  },
  "dependencies": {
    "chalk": "^5.3.0",
    "child_process": "^1.0.2",
    "commander": "^11.0.0",
    "download-git-repo": "^3.0.2",
    "fs-extra": "^11.1.1",
    "handlebars": "^4.7.7",
    "inquirer": "^9.2.7",
    "ora": "^6.3.1",
    "semver": "^7.3.8"
  }
}

package.json type字段设置为module
依赖升级,把原有支持commonJs的package,升级为支持ESM格式

3. 文件后缀js修改为ts,补充文件内容的类型定义

截图.png

由于 download-git-repo 缺少对应的类型定义,需要手动补充定义

@types/download-git-repo.d.ts

declare module 'download-git-repo' {
  export default function download(repo: string, destination: string, options?: any): Promise<void>
}

4. eslint规则调整

.eslintrc.json

{
  // 停止在父级目录中寻找配置
  "root": true,
  "parser": "@typescript-eslint/parser",

  // 配置多个环境
  "env": {
    "es2021": true,
    "node": true
  },

  // 让prettier的解析作为eslint的一部分
  "extends": ["plugin:@typescript-eslint/recommended", "prettier"],
  "parserOptions": {
    // 支持最新的ECMAScript语法
    "ecmaVersion": "latest"
  },

  // 关闭和eslint中以及和其他拓展中有冲突的规则
  "plugins": ["@typescript-eslint", "prettier"],
  "rules": {
    "prettier/prettier": "error",
    "no-console": "off",
    "global-require": "off",
    "import/no-dynamic-require": "off",
    "import/no-import-module-exports": "off"
  }
}

5. rollup打包逻辑调整

rollup.config.ts

import { defineConfig } from 'rollup'
import json from '@rollup/plugin-json'
import terser from '@rollup/plugin-terser'
import typescript from '@rollup/plugin-typescript'
import commonjs from '@rollup/plugin-commonjs'
import resolve from '@rollup/plugin-node-resolve'

export default defineConfig({
  input: './bin/index.ts',
  output: {
    format: 'esm',
    banner: '#!/usr/bin/env node',
    file: './dist/bundle.js',
  },
  external: [
    'commander',
    'inquirer',
    'child_process',
    'download-git-repo',
    'handlebars',
    'ora',
    'semver',
    'chalk',
  ],
  plugins: [typescript(), resolve(), commonjs(), json(), terser()],
})

添加 @rollup/plugin-commonjs@rollup/plugin-node-resolve,处理外部依赖,打包时把package.json打进bundle.js中
添加 '@rollup/plugin-typescript' 编译ts文件

6. 添加husky、lint-stage

pnpm dlx husky-init && pnpm install # pnpm

./husky/pre-commit

#!/usr/bin/env sh 
. "$(dirname -- "$0")/_/husky.sh" 
echo husky running 
npx lint-staged

添加.lintstagedrc.json

{
  "*.ts": ["eslint --fix", "tsc --noEmit"]
}

7. commitizen配置

全局安装

pnpm install -g commitizen

项目安装

pnpm add -D cz-git

添加 config 指定使用的适配器,添加commit脚本,代替 git add . && git commit

{ ... 
  "scripts": { 
     "commit": "git add . && git-cz" 
  },
  "config": { 
     "commitizen": { 
        "path": "node_modules/cz-git" 
     }
  }
}

添加文件commitlint.config.cjs模版

// .commitlintrc.js
/** @type {import('cz-git').UserConfig} */
module.exports = {
  rules: {
    // @see: https://commitlint.js.org/#/reference-rules
  },
  prompt: {
    alias: { fd: 'docs: fix typos' },
    messages: {
      type: '选择你要提交的类型 :',
      scope: '选择一个提交范围(可选):',
      customScope: '请输入自定义的提交范围 :',
      subject: '填写简短精炼的变更描述 :\n',
      body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n',
      breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n',
      footerPrefixesSelect: '选择关联issue前缀(可选):',
      customFooterPrefix: '输入自定义issue前缀 :',
      footer: '列举关联issue (可选) 例如: #31, #I3244 :\n',
      confirmCommit: '是否提交或修改commit ?'
    },
    types: [
      { value: 'feat', name: 'feat:     新增功能 | A new feature' },
      { value: 'fix', name: 'fix:      修复缺陷 | A bug fix' },
      { value: 'docs', name: 'docs:     文档更新 | Documentation only changes' },
      { value: 'style', name: 'style:    代码格式 | Changes that do not affect the meaning of the code' },
      { value: 'refactor', name: 'refactor: 代码重构 | A code change that neither fixes a bug nor adds a feature' },
      { value: 'perf', name: 'perf:     性能提升 | A code change that improves performance' },
      { value: 'test', name: 'test:     测试相关 | Adding missing tests or correcting existing tests' },
      { value: 'build', name: 'build:    构建相关 | Changes that affect the build system or external dependencies' },
      { value: 'ci', name: 'ci:       持续集成 | Changes to our CI configuration files and scripts' },
      { value: 'revert', name: 'revert:   回退代码 | Revert to a commit' },
      { value: 'chore', name: 'chore:    其他修改 | Other changes that do not modify src or test files' },
    ],
    useEmoji: false,
    emojiAlign: 'center',
    themeColorCode: '',
    scopes: [],
    allowCustomScopes: true,
    allowEmptyScopes: true,
    customScopesAlign: 'bottom',
    customScopesAlias: 'custom',
    emptyScopesAlias: 'empty',
    upperCaseSubject: false,
    markBreakingChangeMode: false,
    allowBreakingChanges: ['feat', 'fix'],
    breaklineNumber: 100,
    breaklineChar: '|',
    skipQuestions: [],
    issuePrefixes: [
      // 如果使用 gitee 作为开发管理
      { value: 'link', name: 'link:     链接 ISSUES 进行中' },
      { value: 'closed', name: 'closed:   标记 ISSUES 已完成' }
    ],
    customIssuePrefixAlign: 'top',
    emptyIssuePrefixAlias: 'skip',
    customIssuePrefixAlias: 'custom',
    allowCustomIssuePrefix: true,
    allowEmptyIssuePrefix: true,
    confirmColorize: true,
    maxHeaderLength: Infinity,
    maxSubjectLength: Infinity,
    minSubjectLength: 0,
    scopeOverrides: undefined,
    defaultBody: '',
    defaultIssues: '',
    defaultScope: '',
    defaultSubject: ''
  }
}

结语

最终的脚手架设计算是完整了,后期有补充的内容再继续完善!
链接
npm包