目录

💻 从 0 到 1 封装一个前端脚手架

本篇文章旨在带你从 0 到 1 围绕项目 “创建、运行、测试、提交、发布” 的常规流程进行前端脚手架工具的封装,满足大中小项目开发所需,欢迎您的指正和点赞。

理解脚手架

🤔 前端不是写写页面就可以了吗?为什么要做脚手架开发?

大部分前端工程师都要使用脚手架,最常见的脚手架有:npm、vue-cli、create-react-app、webpack-cli 等等,实际工作中,我们往往需要借助脚手架来做各种流程的自动化,如:项目创建、项目构建、项目发布等。

具有一定规模的前端团队,需要各种各样的提效工具,这类工具可以帮助企业大幅度节约研发成本。另外,随着前端技术的普及,脚手架也成为前端工程师的基本技能。

脚手架简介

🤔 什么是脚手架?

脚手架本质是一个操作系统的客户端,它通过命令行执行,如:

vue create vue-test-app

这条命令由 3 个部分组成:

  • 主命令:vue
  • command:create
  • command 的 param:vue-test-app

它表示创建一个 vue 项目,项目名称为 vue-test-app,这是一个较为简单的脚手架命令,实际场景往往更加复杂,比如:

当前目录已经有文件了,需要覆盖当前目录下的文件,强制进行创建 vue 项目,此时可以输入:

vue create vue-test-app --force

这里的 –force 称为 option,用来辅助脚手架确认在特定场景下用户的选择。–force 可以理解为:–force true,简写为:–force 或 -f。

还有一种场景:

通过 vue create 创建项目时,会自动执行 npm install 帮用户安装依赖,如果我们希望使用淘宝源来安装,可以这样输入:

vue create vue-test-app --force -r https://registry.npm.taobao.org

这里的 -r 也称为 option,它与 –force 不同的是,它使用简写,这里的 -r 也可以替换成 –registry。

-r https://registry.npm.taobao.org 后面的 https://registry.npm.taobao.org 称为 option 的 param。

输入下面的命令就可以看到 vue create 支持的所有 options:

vue create --help

脚手架实现原理

我们以使用 @vue/cli 新建一个 vue 项目为例,首先要全局安装 @vue/cli:

npm install -g @vue/cli
# OR
yarn global add @vue/cli

然后创建一个项目:

vue create vue-test-app

接下来,我们思考一些问题 (以 npm 作为包管理器)。

🤔 全局安装 @vue/cli 时发生了什么?

🤔 为什么全局安装 @vue/cli 后会添加 vue 命令?

🤔 输入命令 vue create vue-test-app 发生了什么?

🤔 为什么 vue 指向一个 js 文件,我们却可以直接通过 vue 命令直接去执行它?

  • @vue/cli 是一个 npm 项目,包含一个 bin/vue.js 文件并已经发布到 npm
  • 执行安装命令,npm 会从 npm 注册表中下载 @vue/cli 的最新版本及其依赖项
  • 命令行工具会把 @vue/cli 安装到 node 的 lib/node_modules
  • 在环境变量中注册 vue 命令,并配置软链接指向:lib/node_modules/@vue/cli/bin/vue.js
  • 在终端输入 vue create vue-test-app,终端解析出 vue 命令
  • 终端在环境变量中找到 vue 命令,根据 vue 命令指向的软链接找到实际文件 vue.js
  • 终端利用 node 执行 vue.js,解析 command/options,执行 command
  • 执行完毕,退出执行

🤔 如何开发一个脚手架?

以 vue-cli 为例:

  • 开发一个 npm 项目,该项目中包含一个 bin/vue.js 文件,将这个项目发布到 npm
  • 将 npm 项目安装到 node 的 lib/node_modules
  • 在 node 的 bin 目录下配置 vue 软链接指向 lib/node_modules/@vue/cli/bin/vue.js

这样我们在执行 vue 命令的时候就可以找到 vue.js 进行执行。

脚手架前置知识

常用的脚手架框架

  • 脚手架生成器

脚手架创建流程

  • 创建 npm 项目,执行 npm init
  • 创建脚手架入口文件,最上方添加
#!/usr/bin/env node
  • 配置 package.json,添加 bin 属性,指向入口文件

脚手架开发流程

  • 分包:将复杂的系统拆分成若干个模块
  • 命令注册:
vue create
vue add
vue invoke
  • 参数解析
  1. options 全称:–version、–help
  2. options 简写:-V、-h
  3. 带 params 的 options:–path /Users/qianwang/Desktop/vue-test
  • global help
  1. Usage
  2. Options
  3. Commands

🌰 vue 的帮助信息:

  • command help
  1. Usage
  2. Options

🌰 vue create 的帮助信息:

还有很多,比如:

  • 命令行交互
  • 日志打印
  • 命令行文字变色
  • 网络通信:HTTP
  • 文件处理

等等…

脚手架调试流程

  • 链接本地脚手架:
cd your-cli-dir
npm link
  • 链接本地库文件
cd your-lib-dir
npm link
cd your-cli-dir
npm link your-lib
  • 取消链接本地库文件:
cd your-lib-dir
npm unlink
cd your-cli-dir
# link存在
npm unlink your-lib
# link不存在
rm -rf node_modules
npm install -S your-lib

🧠 理解 npm link:

  • npm link your-lib:将当前项目项目中 node_modules 下指定的库文件链接到 node 全局 node_modules 下的库文件

  • npm link:将当前项目链接到 node 全局 node_modules 中作为一个库文件,并解析 bin 配置创建可执行文件

🧠 理解 npm unlink:

  • npm unlink:将当前项目从 node 全局 node_modules 中移除
  • npm unlink your-lib:将当前项目中的库文件依赖移除

脚手架发布流程

  • 将脚手架发布到 npm

脚手架使用流程

  • 安装脚手架
npm install -g your-own-cli
  • 使用脚手架
your-own-cli

所用技术栈

  1. Lerna

    • 用途:用于管理 JavaScript 项目的多包工具,能够处理 monorepo 项目,简化包的管理和版本控制。
    • 官网Lerna
  2. Chalk

    • 用途:一个用于在控制台输出带颜色和样式的文本的库,提供简洁的 API。
    • 官网Chalk
  3. Commander

    • 用途:一个用于创建可执行命令行应用程序的库,帮助构建友好的 CLI 接口。
    • 官网Commander
  4. dirname-filename-esm

    • 用途:在 ES 模块中获取当前文件的目录名和文件名,解决 ES 模块中的路径问题。
    • 官网dirname-filename-esm
  5. fs-extra

    • 用途:一个扩展的文件系统库,提供了 FS 模块中没有的额外功能,如递归复制、删除等。
    • 官网fs-extra
  6. import-local

    • 用途:用于在本地查找依赖模块,不会干扰全局安装的模块,通常用于 CLI 工具中。
    • 官网import-local
  7. semver

    • 用途:一个用于解析、比较和操作语义化版本号(semver)的库。
    • 官网semver
  8. execa

    • 用途:一个用于执行子进程并处理其输出的库,提供更好的人机界面,以处理和简化命令行的执行。
    • 官网execa
  9. Jest

    • 用途:由 Facebook 开发的 JavaScript 测试框架,适用于 React 应用和其他 JavaScript 项目,具有零配置的特性。
    • 官网Jest
  10. Mocha

    • 用途:一个灵活的 JavaScript 测试框架,支持异步测试和多种断言库。
    • 官网Mocha
  11. Axios

    • 用途:一个用于发送 HTTP 请求的库,支持 Promise API 和请求/响应拦截器。
    • 官网Axios
  12. Inquirer

    • 用途:用于构建命令行交互式问答的库,广泛用于 CLI 应用中获取用户输入。
    • 官网Inquirer
  13. npmlog

    • 用途:一个用于记录 NPM 日志的库,提供多种日志级别。
    • 官网npmlog
  14. url-join

    • 用途:一个用于连接 URL 片段的简单库,确保正确处理斜杠。
    • 官网url-join
  15. path-exists

    • 用途:检查文件或目录是否存在的库。
    • 官网path-exists
  16. simple-git

    • 用途:一个简化 Git 操作的 Node.js 库,用于在 Node 应用中执行 Git 命令。
    • 官网simple-git
  17. Ora

    • 用途:一个用于在命令行中显示加载动画的库,常用于指示异步操作的进度。
    • 官网Ora
  18. EJS

    • 用途:一个简单的 JavaScript 模板引擎,用于生成 HTML 内容,支持在 JavaScript 中嵌入 HTML 代码。
    • 官网EJS
  19. Glob

    • 用途:用于文件匹配(文件搜索)的库,允许使用 Unix 风格的图案匹配文件名。
    • 官网Glob
  20. ESLint

    • 用途:一个用于识别和报告 JavaScript 代码中模式问题的工具,帮助保持代码质量。
    • 官网ESLint
  21. Egg.js

    • 用途:一个基于 Koa 的企业级 Node.js web 框架,提供强大的插件系统和架构。
    • 官网Egg.js
  22. egg-mongoose

    • 用途:Egg.js 框架的 Mongoose 插件,用于简单地连接 MongoDB 数据库。
    • 官网egg-mongoose
  23. MongoDB

    • 用途:一个现代化的 NoSQL 数据库,可用于存储和查询数据,适合处理非结构化数据。
    • 官网MongoDB

搭建脚手架

通过 lerna 创建 package:

packages
├── cli # 脚手架入口
├── command # 通用脚手架框架
├── commit # 代码提交工具
├── init # 项目创建工具
├── install # 源码下载工具
├── lint # 代码规范检查工具
└── utils # 通用工具类

通用脚手架框架

packages/utils

调整 package.json
{
  "name": "@oweqian/utils",
  "version": "0.0.0",
  "description": "cli-oweqian utils",
  // ...
  "main": "lib/index.js",
  "directories": {
    "lib": "lib",
    "test": "__tests__"
  },
  "files": [
    "lib"
  ],
  // ...
  "dependencies": {
    "axios": "^1.1.3",
    "execa": "^6.1.0",
    "fs-extra": "^10.1.0",
    "inquirer": "^9.1.3",
    "npmlog": "^6.0.2",
    "path-exists": "^5.0.0",
    "url-join": "^5.0.0"
  },
  "type": "module"
}

package.json 中 type = “module” 时,.js 后缀文件会被识别为 ESM,如果不指定默认为 commonjs。

入口文件
import isDebug from "./isDebug.js";
import log from "./log.js";
import printErrorLog from "./printErrorLog.js";
import { makeList, makeInput, makePassword } from "./inquirer.js";
import { getLatestVersion } from "./npm.js";
import request from "./request.js";
import Github from "./git/Github.js";
import Gitee from "./git/Gitee.js";
import { getGitPlatform, clearCache } from "./git/GitServer.js";
import {
  initGitServer,
  initGitType,
  createRemotoRepo,
} from "./git/GitUtils.js";

export {
  log,
  isDebug,
  makeList,
  makeInput,
  makePassword,
  getLatestVersion,
  printErrorLog,
  request,
  Github,
  Gitee,
  getGitPlatform,
  initGitServer,
  initGitType,
  clearCache,
  createRemotoRepo,
};
封装 isDebug
/**
 * 检测当前 Node.js 进程是否以调试模式启动
 * 通过指定命令行参数 --debug 或 -d
 */
export default function isDebug() {
  return process.argv.includes("--debug") || process.argv.includes("-d");
}

process.argv 是 Node.js 提供的一个数组,其中包含了命令行输入的参数。第一个元素是 Node.js 可执行文件的路径,第二个元素是被执行的 JavaScript 文件的路径,后面的元素是传递给脚本的命令行参数。

封装 npmlog
import log from "npmlog";
import isDebug from "./isDebug.js";

/**
 * 打印日志
 */
if (isDebug()) {
  log.level = "verbose";
} else {
  log.level = "info";
}

log.heading = "oweqian";
log.addLevel("success", 2000, { fg: "green", bold: true });

export default log;
封装 printErrorLog
import log from "./log.js";
import isDebug from "./isDebug.js";

function printErrorLog(e, type) {
  if (isDebug()) {
    log.error(type, e);
  } else {
    log.error(type, e.message);
  }
}

export default printErrorLog;
封装 inquirer
import inquirer from "inquirer";

function make({
  type = "list",
  message = "请选择",
  defaultValue,
  validate,
  require = true,
  pageSize,
  mask = "*",
  choices,
  loop,
}) {
  const options = {
    name: "name",
    type,
    message,
    default: defaultValue,
    validate,
    require,
    pageSize,
    mask,
    loop,
  };
  if (type === "list") {
    options.choices = choices;
  }

  return inquirer.prompt(options).then((answer) => answer.name);
}

// list
export function makeList(params) {
  return make({ ...params });
}

// input
export function makeInput(params) {
  return make({
    ...params,
    type: "input",
  });
}

// password
export function makePassword(params) {
  return make({
    ...params,
    type: "password",
  });
}
封装 npm api
import urlJoin from "url-join";
import axios from "axios";
import log from "./log.js";

/**
 * 获取 package 信息
 */
function getNpmInfo(npmName) {
  const registry = "https://registry.npmjs.org/";
  const url = urlJoin(registry, npmName);
  return axios.get(url).then((response) => {
    try {
      return response.data;
    } catch (err) {
      return Promise.reject(err);
    }
  });
}

/**
 * 获取 package 最新版本
 */
export function getLatestVersion(npmName) {
  return getNpmInfo(npmName).then((data) => {
    if (!data["dist-tags"] || !data["dist-tags"].latest) {
      log.error("没有 latest 版本号");
      return Promise.reject(new Error("没有 latest 版本号"));
    }
    return data["dist-tags"].latest;
  });
}
封装 axios
import axios from "axios";

const BASE_URL = "http://127.0.0.1:7001";

const service = axios.create({
  baseURL: BASE_URL,
  timeout: 5000,
});

// 成功
function onSuccess(response) {
  return response.data;
}

// 失败
function onFailed(error) {
  return Promise.reject(error);
}

// 响应拦截器
service.interceptors.response.use(onSuccess, onFailed);

export default service;
封装 Git
GitServer 类
import path from "node:path";
import { execa } from "execa";
import { pathExistsSync } from "path-exists";
import fse from "fs-extra";
import { homedir } from "node:os";
import { makePassword } from "../inquirer.js";
import log from "../log.js";

const TEMP_HOME = ".cli-oweqian";
// 缓存 git token
const TEMP_TOKEN = ".git_token";
// 缓存 git 托管平台信息
const TEMP_PLATFORM = ".git_platform";
// 缓存 git 平台用户信息
const TEMP_OWN = ".git_own";
// 缓存 git 平台组织信息
const TEMP_LOGIN = ".git_login";

function createTokenPath() {
  return path.resolve(homedir(), TEMP_HOME, TEMP_TOKEN);
}

function createPlatformPath() {
  return path.resolve(homedir(), TEMP_HOME, TEMP_PLATFORM);
}

function createOwnPath() {
  return path.resolve(homedir(), TEMP_HOME, TEMP_OWN);
}

function createLoginPath() {
  return path.resolve(homedir(), TEMP_HOME, TEMP_LOGIN);
}

function getGitPlatform() {
  const platformPath = createPlatformPath();
  if (pathExistsSync(platformPath)) {
    return fse.readFileSync(platformPath).toString();
  }
  return null;
}

function getGitOwn() {
  const ownPath = createOwnPath();
  if (pathExistsSync(ownPath)) {
    return fse.readFileSync(ownPath).toString();
  }
}

function getGitLogin() {
  const loginPath = createLoginPath();
  if (pathExistsSync(loginPath)) {
    return fse.readFileSync(loginPath).toString();
  }
}

function getProjectPath(cwd, fullName) {
  const projectName = fullName.split("/")[1];
  const projectPath = path.resolve(cwd, projectName);
  return projectPath;
}

function getPackageJson(cwd, fullName) {
  const projectPath = getProjectPath(cwd, fullName);
  const pkgPath = path.resolve(projectPath, "package.json");
  if (pathExistsSync(pkgPath)) {
    return fse.readJSONSync(pkgPath);
  }
  return null;
}

/**
 * 清除缓存
 */
function clearCache() {
  const platform = createPlatformPath();
  const token = createTokenPath();
  const own = createOwnPath();
  const login = createLoginPath();
  fse.removeSync(platform);
  fse.removeSync(token);
  fse.removeSync(own);
  fse.removeSync(login);
}

/**
 * 封装 GitServer 类
 */
class GitServer {
  constructor() {}

  // 初始化
  async init() {
    // 判断 git token 是否存在
    const tokenPath = createTokenPath();
    if (pathExistsSync(tokenPath)) {
      this.token = fse.readFileSync(tokenPath).toString();
    } else {
      this.token = await this.getToken();
      fse.writeFileSync(tokenPath, this.token);
    }
    log.verbose("token", this.token);
    log.verbose("token path", pathExistsSync(tokenPath));
  }

  getToken() {
    return makePassword({
      message: "请输入 token 的信息",
    });
  }

  savePlatform(platform) {
    this.platform = platform;
    fse.writeFileSync(createPlatformPath(), platform);
  }

  saveOwn(own) {
    this.own = own;
    fse.writeFileSync(createOwnPath(), own);
  }

  saveLogin(login) {
    this.login = login;
    fse.writeFileSync(createLoginPath(), login);
  }

  getPlatform() {
    return this.platform;
  }

  getOwn() {
    return this.own;
  }

  getLogin() {
    return this.login;
  }

  // 克隆仓库
  cloneRepo(fullName, tag) {
    if (tag) {
      return execa("git", ["clone", this.getRepoUrl(fullName), "-b", tag]);
    }
    return execa("git", ["clone", this.getRepoUrl(fullName)]);
  }

  // 安装依赖
  installDependencies(cwd, fullName) {
    const projectPath = getProjectPath(cwd, fullName);
    if (pathExistsSync(projectPath)) {
      return execa(
        "npm",
        ["install", "--registry=https://registry.npmmirror.com"],
        { cwd: projectPath }
      );
    }
  }

  // 启动项目
  async runRepo(cwd, fullName) {
    const projectPath = getProjectPath(cwd, fullName);
    const pkg = getPackageJson(cwd, fullName);

    if (pkg) {
      const { scripts, bin, name } = pkg;
      // 可执行文件(:脚手架
      if (bin) {
        await execa(
          "npm",
          ["install", "-g", name, "--registry=https://registry.npmmirror.com"],
          {
            cwd: projectPath,
            stdout: "inherit",
          }
        );
      }

      // 项目
      if (scripts && scripts.dev) {
        return execa("npm", ["run", "dev"], {
          cwd: projectPath,
          stdout: "inherit",
        });
      } else if (scripts && scripts.start) {
        return execa("npm", ["run", "start"], {
          cwd: projectPath,
          stdout: "inherit",
        });
      } else {
        log.warn("未找到启动命令");
      }
    }
  }

  getUser() {
    throw new Error("gitUser must be implemented!");
  }

  getOrganization() {
    throw new Error("getOrganization must be implemented!");
  }

  createRepo() {
    throw new Error("createRepo must be implemented!");
  }
}

export { getGitPlatform, GitServer, clearCache, getGitOwn, getGitLogin };
Github
import axios from "axios";
import { GitServer } from "./GitServer.js";
import log from "../log.js";

const BASE_URL = "https://api.github.com";

class Github extends GitServer {
  constructor() {
    super();

    this.service = axios.create({
      baseURL: BASE_URL,
      timeout: 50000,
    });

    // 请求拦截器
    this.service.interceptors.request.use(
      (config) => {
        config.headers["Authorization"] = `Bearer ${this.token}`;
        config.headers["Accept"] = "application/vnd.github+json";
        return config;
      },
      (error) => {
        return Promise.reject(error);
      }
    );

    // 响应拦截器
    this.service.interceptors.response.use(
      (response) => {
        return response.data;
      },
      (error) => {
        return Promise.reject(error);
      }
    );
  }

  // 封装 get 方法
  get(url, params, headers) {
    return this.service({
      url,
      params,
      method: "get",
      headers,
    });
  }

  // 封装 post 方法
  post(url, data, headers) {
    return this.service({
      url,
      data,
      method: "post",
      headers,
    });
  }

  // 搜索仓库
  searchRepositories(params) {
    return this.get("/search/repositories", params);
  }

  // 搜索源码
  searchCode(params) {
    return this.get("/search/code", params);
  }

  // 获取 tags
  getTags(fullName, params) {
    console.log(`/repos/${fullName}/tags`);
    return this.get(`/repos/${fullName}/tags`, params);
  }

  getRepoUrl(fullName) {
    return `https://github.com/${fullName}.git`;
  }

  getUser() {
    return this.get("/user");
  }

  getOrganization() {
    return this.get("/user/orgs");
  }

  getRepo(login, repo) {
    return this.get(
      `/repos/${login}/${repo}`,
      {},
      {
        accept: "application/vnd.github+json",
      }
    ).catch((err) => {
      return null;
    });
  }

  async createRepo(name) {
    // 检查远程仓库是否存在,如果存在,则跳过创建
    const repo = await this.getRepo(this.login, name);
    if (!repo) {
      log.info("仓库不存在,开始创建");
      if (this.own === "user") {
        return this.post(
          "/user/repos",
          { name },
          {
            accept: "application/vnd.github+json",
          }
        );
      } else if (this.own === "organization") {
        return this.post(
          `/orgs/${this.login}/repos`,
          { name },
          {
            accept: "application/vnd.github+json",
          }
        );
      }
    } else {
      log.info("仓库存在,直接返回");
      return repo;
    }
  }
}

export default Github;
Gitee
import axios from "axios";
import { GitServer } from "./GitServer.js";
import log from "../log.js";

const BASE_URL = "https://gitee.com/api/v5";

class Gitee extends GitServer {
  constructor() {
    super();

    this.service = axios.create({
      baseURL: BASE_URL,
      timeout: 50000,
    });

    // 响应拦截器
    this.service.interceptors.response.use(
      (response) => {
        return response.data;
      },
      (error) => {
        return Promise.reject(error);
      }
    );
  }

  // 封装 get 方法
  get(url, params, headers) {
    return this.service({
      url,
      params: {
        ...params,
        access_token: this.token,
      },
      method: "get",
      headers,
    });
  }

  // 封装 post 方法
  post(url, data, headers) {
    return this.service({
      url,
      data: {
        ...data,
        access_token: this.token,
      },
      method: "post",
      headers,
    });
  }

  // 搜索仓库
  searchRepositories(params) {
    return this.get("/search/repositories", params);
  }

  // 获取 tags
  getTags(fullName) {
    return this.get(`/repos/${fullName}/tags`);
  }

  getRepoUrl(fullName) {
    return `https://gitee.com/${fullName}.git`;
  }

  getUser() {
    return this.get("/user");
  }

  getOrganization() {
    return this.get("/user/orgs");
  }

  getRepo(owner, repo) {
    return this.get(`/repos/${owner}/${repo}`).catch((err) => {
      return null;
    });
  }

  async createRepo(name) {
    // 检查远程仓库是否存在,如果存在,则跳过创建
    const repo = await this.getRepo(this.login, name);
    if (!repo) {
      log.info("仓库不存在,开始创建");
      if (this.own === "user") {
        return this.post("/user/repos", { name });
      } else if (this.own === "organization") {
        return this.post(`/orgs/${this.login}/repos`, { name });
      }
    } else {
      log.info("仓库存在,直接返回");
      return repo;
    }
  }
}

export default Gitee;
Git Utils
import { getGitPlatform, getGitLogin, getGitOwn } from "./GitServer.js";
import { makeList } from "../inquirer.js";
import log from "../log.js";
import Gitee from "./Gitee.js";
import Github from "./Github.js";

/**
 * 实例化 GitServer 对象、git 托管平台、git token
 */
export async function initGitServer() {
  let platform = getGitPlatform();
  if (!platform) {
    platform = await makeList({
      message: "请选择 Git 平台",
      choices: [
        {
          name: "Github",
          value: "github",
        },
        {
          name: "Gitee",
          value: "gitee",
        },
      ],
    });
  }
  log.verbose("platform", platform);

  let gitAPI;
  if (platform === "github") {
    gitAPI = new Github();
  } else {
    gitAPI = new Gitee();
  }
  gitAPI.savePlatform(platform);
  await gitAPI.init();
  return gitAPI;
}

/**
 * git 平台用户信息、git 平台组织信息
 */
export async function initGitType(gitAPI) {
  // 用户信息
  let gitOwn = getGitOwn();
  // 组织信息
  let gitLogin = getGitLogin();

  if (!gitLogin && !gitOwn) {
    const user = await gitAPI.getUser();
    const organization = await gitAPI.getOrganization();
    log.verbose("user", user);
    log.verbose("organization", organization);

    // 选择仓库类型
    if (!gitOwn) {
      gitOwn = await makeList({
        message: "请选择仓库类型",
        choices: [
          {
            name: "User",
            value: "user",
          },
          {
            name: "Organization",
            value: "organization",
          },
        ],
      });
      log.verbose("gitOwn", gitOwn);
    }

    if (gitOwn === "user") {
      gitLogin = user?.login;
    } else {
      const organizationList = organization.map((item) => ({
        name: item.name || item.login,
        value: item.login,
      }));
      gitLogin = await makeList({
        message: "请选择组织",
        choices: organizationList,
      });
    }
    log.verbose("gitLogin", gitLogin);
  }

  if (!gitLogin || !gitOwn) {
    throw new Error(
      '未获取到用户的 Git 登录信息!请使用 "cli-oweqian commit --clear" 清除缓存后重试'
    );
  }

  gitAPI.saveOwn(gitOwn);
  gitAPI.saveLogin(gitLogin);
  return gitLogin;
}

export async function createRemotoRepo(gitAPI, name) {
  const ret = await gitAPI.createRepo(name);
}

packages/command

调整 package.json
{
  "name": "@oweqian/command",
  "version": "0.0.0",
  "description": "common command class",
  "main": "lib/index.js",
  // ...
  "type": "module"
}
封装 Command 类 ⭐️

基于 Commander.js 进行二次封装,提供了一种模板化的方法来处理命令创建和执行。具体的命令需要通过继承 Command 类并实现所需的 getter 和方法来完成,这样做的好处是代码复用和结构清晰,减少了重复的命令设置代码。

/**
 * 封装 Command 类
 */
class Command {
  constructor(instance) {
    // 命令行程序的实例
    if (!instance) {
      throw new Error("command instance must not be null!");
    }

    this.program = instance;

    const cmd = this.program.command(this.command);
    cmd.description(this.description);

    cmd.hook("preAction", () => {
      this.preAction();
    });

    cmd.hook("postAction", () => {
      this.postAction();
    });

    if (this.options?.length > 0) {
      this.options.forEach((option) => {
        cmd.option(...option);
      });
    }

    cmd.action((...params) => {
      this.action(params);
    });
  }

  // 命令的名称
  get command() {
    throw new Error("command must be implements");
  }

  // 命令的描述信息
  get description() {
    throw new Error("description must be implements");
  }

  // 命令的选项
  get options() {
    return [];
  }

  // 命令执行时的动作
  action() {
    throw new Error("action must be implements");
  }

  // 命令执行前的钩子
  preAction() {
    // empty
  }

  // 命令执行后的钩子
  postAction() {
    // empty
  }
}

export default Command;

packages/cli

调整 package.json
{
  "name": "@oweqian/cli",
  "version": "0.0.0",
  "description": "oweqian cli",
  // ...
  "main": "lib/index.js",
  "bin": {
    "cli-oweqian": "bin/cli.js"
  },
  "directories": {
    "lib": "lib",
    "test": "__tests__"
  },
  "files": [
    "lib",
    "bin"
  ],
  "scripts": {
    "test": "jest"
  },
  "dependencies": {
    "@oweqian/commit": "^0.0.0",
    "@oweqian/init": "^0.0.0",
    "@oweqian/install": "^0.0.0",
    "@oweqian/lint": "^0.0.0",
    "@oweqian/utils": "^0.0.0",
    "chalk": "^5.1.0",
    "commander": "^9.4.1",
    "dirname-filename-esm": "^1.1.1",
    "fs-extra": "^10.1.0",
    "import-local": "^3.1.0",
    "semver": "^7.3.8"
  },
  "devDependencies": {
    "@babel/core": "^7.19.3",
    "@babel/preset-env": "^7.19.4",
    "execa": "^6.1.0",
    "jest": "^29.1.2"
  },
  "type": "module"
}
可执行文件
#!/usr/bin/env node

import importLocal from "import-local";
import { filename } from "dirname-filename-esm";
import { log } from "@oweqian/utils";
import entry from "../lib/index.js";

const __filename = filename(import.meta);

// 优先加载本地脚手架版本
if (importLocal(__filename)) {
  log.info("cli", "使用本次 cli-oweqian 版本");
} else {
  entry(process.argv.slice(2));
}
入口文件
import createInitCommand from "@oweqian/init";
import createInstallCommand from "@oweqian/install";
import createLintCommand from "@oweqian/lint";
import createCommitCommand from "@oweqian/commit";
import createCLI from "./createCLI.js";
import "./exception.js";

export default function (args) {
  const program = createCLI();
  // 项目创建工具
  createInitCommand(program);
  // 源码下载工具
  createInstallCommand(program);
  // 代码规范检查工具
  createLintCommand(program);
  // 代码提交工具
  createCommitCommand(program);
  program.parse(process.argv);
}
捕获异常日志
import { printErrorLog } from "@oweqian/utils";

// 处理未捕获的异常
process.on("uncaughtException", (e) => printErrorLog(e, "error"));

// 处理未处理的 Promise Reject
process.on("unhandledRejection", (e) => printErrorLog(e, "promise"));
命令行基础结构
import path from "node:path";
import { program } from "commander";
import { dirname } from "dirname-filename-esm";
import semver from "semver";
import chalk from "chalk";
import fse from "fs-extra";
import { log } from "@oweqian/utils";

// 获取当前模块的目录路径
const __dirname = dirname(import.meta);
// 获取 package.json 文件路径
const pkgPath = path.resolve(__dirname, "../package.json");
// 读取 package.json 文件
const pkg = fse.readJSONSync(pkgPath);

// 定义最低 Node.js 版本
const LOWEST_NODE_VERSION = "14.0.0";

// 检查 Node.js 版本
function checkNodeVersion() {
  // 使用 semver 检查当前运行的 Node.js 版本是否满足最低要求
  if (!semver.gte(process.version, LOWEST_NODE_VERSION)) {
    throw new Error(
      chalk.red(
        `cli-oweqian 需要安装 ${LOWEST_NODE_VERSION} 以上版本的 Node.js`
      )
    );
  }
}

function preAction() {
  // 检查Node版本
  checkNodeVersion();
}

function createCLI() {
  log.info("version", pkg.version);

  program
    .name(Object.keys(pkg.bin)[0])
    .usage("<command> [options]")
    .version(pkg.version)
    .option("-d, --debug", "是否开启调试模式", false)
    .hook("preAction", preAction);

  // 处理 debug 选项
  program.on("option:debug", function () {
    if (program.opts().debug) {
      log.verbose("debug", "launch debug mode!");
    }
  });

  // 处理未知命令
  program.on("command:*", function (obj) {
    log.error("未知的命令:" + obj[0]);
  });

  return program;
}

export default createCLI;

项目创建工具

🤔 为什么要开发创建项目的脚手架工具?

🤔 为什么不直接使用 vue-cli 或 create-react-app?

项目在迭代过程中会添加很多 ”本土化“ 元素,如:H5 兼容、接口请求、埋点上报、组件封装、常用工具函数封装等,甚至会对整块业务逻辑进行复用,如登录、退出等;

如果通过 vue-cli 或 create-react-app 这些通用脚手架,每次在创建项目时都要重新编写或复制这些代码是非常耗时的,而且无法对每个团队成员进行约束,统一项目规范。利用脚手架可以来完成项目模板的沉淀和标准化建设。

准备工作

开发常见项目的脚手架工具前,我们先做一些前期准备工作。

模板配置化

随着项目沉淀,我们会有很多项目模版,如果把这些项目模版写死在脚手架中,就无法进行灵活扩展。为此,我们通过脚手架 + Egg + MongoDB 实现一套可灵活配置和扩展的项目模板管理机制,灵活、快速的实现项目模版的增删改查、分类等功能,解决项目模版无法扩展的问题。

项目模版制作

我们分别用 vue-cli、 create-react-app、vue-element-admin 准备三个项目做项目模版,然后分别发布到 npm 上。

  • @oweqian/template-vue3

npm 地址
项目地址

  • @oweqian/template-react18

npm 地址
项目地址

  • @oweqian/template-vue-element-admin

npm 地址
项目地址

🤔 为什么使用 npm 管理项目模板?

优势:

  • 省去自己搭建静态资源服务器的成本
  • npm 包含了 package 的版本管理机制
  • npm 提供了可以根据 package 名称查询下载地址的 api
  • npm 对所有上传的 package 进行 cdn 加速

(p≧w≦q) 嘻嘻嘻,狠狠地夸就完了~~~~

Egg + MongoDB

我们将通过企业级框架 Egg.js 实现后端服务搭建,并通过 Restful API 实现接口开发。然后在本地搭建 MongoDB 数据库,并导入项目模板数据。

项目地址:cli-oweqian-server

  • 定义路由:
router.resources("/v1/project", controller.v1.project);
  • 定义 GET 方法:
const Controller = require("egg").Controller;

class ProjectController extends Controller {
  // 项目模版查询
  async index() {
    const { ctx } = this;
    const res = await ctx.model.Project.find({});
    ctx.body = res;
  }
  // 项目模版查询
  async show() {
    const { ctx } = this;
    const { id } = ctx.params;
    const res = await ctx.model.Project.find({ value: id });
    if (res.length > 0) {
      ctx.body = res[0];
    } else {
      ctx.body = res;
    }
  }
}
module.exports = ProjectController;
  • 配置插件信息:
// plugin.js
/** @type Egg.EggPlugin */
exports.mongoose = {
  enable: true,
  package: "egg-mongoose",
};
  • 更新配置信息:
// config.default.js
config.mongoose = {
  url: "mongodb://oweqian:123456@127.0.0.1:27017/cli-oweqian",
};
  • 创建 model 信息:
module.exports = (app) => {
  const mongoose = app.mongoose;
  const Schema = mongoose.Schema;
  const ProjectSchema = new Schema({
    name: { type: String },
    value: { type: String },
    npmName: { type: String },
    version: { type: String },
  });
  return mongoose.model("Project", ProjectSchema);
};

默认情况下,会在 collections 后面加 s,如:project,实际查询的是 projects。

  • 查询 model 信息:
const { ctx } = this;
    const res = await ctx.model.Project.find({});
    ctx.body = res;
渲染动态化

上一节我们通过 Egg + MongoDB 存储的方式实现了模板配置化,即通过数据库配置的方式实现模板的修改,而不需要改动脚手架代码。

目前我们的模板都是静态的,即创建时什么样,创建后还是什么样,其中的内容无法进行动态渲染。这是不符合更复杂的需求的,比如 vue 的项目模板就大量使用了 ejs 进行模板的动态渲染。

🤔 如何实现模板渲染动态化?

通过 ejs 改良项目模板,生成动态模板

  • 设计 @cli-oweqian/init 的插件机制
  • 实现一个 init 插件,在插件中实现 ejs 模板渲染逻辑

改造 @oweqian/template-vue3 模板并发布到 npm:

// plugins/index.js
export default async function (api) {
  const mode = await api.makeList({
    choices: [
      {
        name: "API",
        value: "api",
      },
      {
        name: "默认",
        value: "default",
      },
    ],
    message: "请选择代码模式",
  });
  console.log(mode);
  return {
    mode,
  };
}
// template/package.json
{
  "name": "<%= data.name %>",
  // ...
}

调整 package.json

{
  "name": "@oweqian/init",
  "version": "0.0.0",
  "description": "init command",
  // ...
  "main": "lib/index.js",
  "directories": {
    "lib": "lib",
    "test": "__tests__"
  },
  "files": [
    "lib"
  ],
  // ...
  "dependencies": {
    "@oweqian/command": "^0.0.0",
    "@oweqian/utils": "^0.0.0",
    "ejs": "^3.1.8",
    "execa": "^6.1.0",
    "fs-extra": "^10.1.0",
    "glob": "^8.0.3",
    "ora": "^6.1.2",
    "path-exists": "^5.0.0"
  },
  "type": "module"
}

入口文件

import Command from "@oweqian/command";
import { log } from "@oweqian/utils";
import createTemplate from "./createTemplate.js";
import downloadTemplate from "./downloadTemplate.js";
import installTemplate from "./installTemplate.js";

/**
 * 继承 Command 类
 * examples:
 * cli-oweqian init
 * cli-oweqian init aa -t project -tp template-react18 --force --debug
 */
class InitCommand extends Command {
  get command() {
    return "init [name]";
  }

  get description() {
    return "init project";
  }

  get options() {
    return [
      ["-f, --force", "是否强制更新", false],
      ["-t, --type <type>", "创建类型(值:project/page)"],
      ["-tp, --template <template>", "模板名称"],
    ];
  }

  async action([name, opts]) {
    log.verbose("init", name, opts);
    /**
     * 1.选择项目模板,生成项目信息
     */
    const selectedTemplate = await createTemplate(name, opts);
    log.verbose("selectedTemplate", selectedTemplate);
    /**
     * 2.下载项目模板至缓存目录
     */
    await downloadTemplate(selectedTemplate);
    /**
     * 3.安装项目模板至项目目录
     */
    await installTemplate(selectedTemplate, opts);
  }

  preAction() {
    console.log("pre");
  }

  postAction() {
    console.log("post");
  }
}

function Init(instance) {
  return new InitCommand(instance);
}

export default Init;

选择项目模板

提供创建项目的基本信息选择:

  • 创建类型:决定创建的是项目还是页面
  • 项目名称:决定文件夹名称
  • 项目模板:决定项目类型
// ./createTemplate.js
import path from "node:path";
import { homedir } from "node:os";
import {
  makeList,
  log,
  makeInput,
  getLatestVersion,
  request,
  printErrorLog,
} from "@oweqian/utils";

const ADD_TYPE_PROJECT = "project";
const ADD_TYPE_PAGE = "page";
const ADD_TYPE = [
  {
    name: "项目",
    value: ADD_TYPE_PROJECT,
  },
  {
    name: "页面",
    value: ADD_TYPE_PAGE,
  },
];

const TEMP_HOME = ".cli-oweqian";

/**
 * 选择创建类型
 * 决定创建的是项目或者页面
 */
function getAddType() {
  return makeList({
    choices: ADD_TYPE,
    message: "请选择初始化类型",
    defaultValue: ADD_TYPE_PROJECT,
  });
}

/**
 * 输入项目名称
 */
function getAddName() {
  return makeInput({
    message: "请输入项目名称",
    defaultValue: "",
    validate: (v) => {
      if (v.length > 0) {
        return true;
      }
      return "项目名称必须输入";
    },
  });
}

/**
 * 选择项目模板
 */
function getAddTemplate(choices) {
  return makeList({
    choices,
    message: "请选择项目模板",
  });
}

/**
 * 选择所在团队
 * 不同的团队有不同的项目模板
 */
function getAddTeam(team) {
  return makeList({
    choices: team.map((item) => ({ name: item, value: item })),
    message: "请选择团队",
  });
}

/**
 * 模板缓存目录
 * os.homedir(): 返回当前用户的主目录的字符串路径
 */
function makeTargetPath() {
  return path.resolve(`${homedir()}/${TEMP_HOME}`, "addTemplate");
}

/**
 * 通过API获取项目模板
 */
async function getTemplateFromAPI() {
  try {
    const data = await request({
      url: "/v1/project",
      method: "get",
    });
    log.verbose("template", data);
    return data;
  } catch (e) {
    printErrorLog(e);
    return null;
  }
}

async function createTemplate(name, opts) {
  // 通过API获取项目模板
  const ADD_TEMPLATE = await getTemplateFromAPI();
  if (!ADD_TEMPLATE) {
    throw new Error("项目模板不存在");
  }

  /**
   * name:项目名称
   * type:创建类型(值:project/page)
   * template:模板名称
   */
  const { type = null, template = null } = opts;

  // 项目名称
  let addName;
  // 选择的创建类型
  let addType;
  // 选择的模板信息
  let selectedTemplate;

  // 兼容交互式(Options)和非交互式项目创建逻辑
  if (type) {
    addType = type;
  } else {
    addType = await getAddType();
  }
  log.verbose("addType", addType);

  if (addType === ADD_TYPE_PROJECT) {
    if (name) {
      addName = name;
    } else {
      addName = await getAddName();
    }
    log.verbose("addName", addName);

    if (template) {
      selectedTemplate = ADD_TEMPLATE.find((tp) => tp.value === template);
      if (!selectedTemplate) {
        throw new Error(`项目模板 ${template} 不存在`);
      }
    } else {
      // 获取团队信息
      let teamList = ADD_TEMPLATE.map((_) => _.team);
      teamList = [...new Set(teamList)];

      const addTeam = await getAddTeam(teamList);
      log.verbose("addTeam", addTeam);

      // 根据选择的团队信息过滤模板
      let addTemplate = await getAddTemplate(
        ADD_TEMPLATE.filter((_) => _.team === addTeam)
      );
      log.verbose("addTemplate", addTemplate);

      selectedTemplate = ADD_TEMPLATE.find((_) => _.value === addTemplate);
    }
    log.verbose("selectedTemplate", selectedTemplate);

    // 通过 package 名称从 npm 获取最新版本号
    const latestVersion = await getLatestVersion(selectedTemplate.npmName);
    log.verbose("latestVersion", latestVersion);
    selectedTemplate.version = latestVersion;

    // 模板缓存目录
    const targetPath = makeTargetPath();

    return {
      type: addType,
      name: addName,
      template: selectedTemplate,
      targetPath,
    };
  } else {
    throw new Error(`类型 ${addType} 不支持`);
  }
}

export default createTemplate;

下载项目模板

根据上一步选择的项目模板:

  • 生成缓存目录
  • 利用 npm api 接口获取下载路径
  • 下载模板至缓存目录
// ./downloadTemplate.js
import path from "node:path";
import fse from "fs-extra";
import ora from "ora";
import { execa } from "execa";
import { pathExistsSync } from "path-exists";
import { log, printErrorLog } from "@oweqian/utils";

/**
 * 🐞 下载的文件夹下需要安装node_modules目录,否则直接使用 npm install 无法下载成功
 */
function getCacheDir(targetPath) {
  return path.resolve(targetPath, "node_modules");
}

function makeCacheDir(targetPath) {
  const cacheDir = getCacheDir(targetPath);
  // 判断文件夹是否存在
  if (!pathExistsSync(cacheDir)) {
    // 创建文件夹
    fse.mkdirpSync(cacheDir);
  }
}

async function downloadAddTemplate(targetPath, selectedTemplate) {
  const { npmName, version } = selectedTemplate;
  const installCommand = "npm";
  const installArgs = ["install", `${npmName}@${version}`];
  const cwd = targetPath;
  log.verbose("installArgs", installArgs);
  log.verbose("cwd", cwd);
  await execa(installCommand, installArgs, { cwd });
}

async function downloadTemplate(selectedTemplate) {
  const { targetPath, template } = selectedTemplate;
  makeCacheDir(targetPath);

  // 下载进度显示
  const spinner = ora("正在下载模板...").start();

  try {
    // 下载模板至缓存目录
    await downloadAddTemplate(targetPath, template);
    log.success("下载模板成功");
  } catch (e) {
    printErrorLog(e);
  } finally {
    spinner.stop();
  }
}

export default downloadTemplate;

安装项目模板

下载模板到指定的缓存目录后:

  • 从缓存目录拷贝模板文件到项目目录下
  • 忽略特定目录或文件
  • 根据 –force option 强制安装
// ./installTemplate.js
import path from "node:path";
import fse from "fs-extra";
import ora from "ora";
import ejs from "ejs";
import glob from "glob";
import { pathExistsSync } from "path-exists";
import { log, makeInput, makeList } from "@oweqian/utils";

function getCacheFilePath(targetPath, template) {
  return path.resolve(targetPath, "node_modules", template.npmName, "template");
}

function copyFile(targetPath, template, installDir) {
  // 从缓存目录找模板
  const originFile = getCacheFilePath(targetPath, template);
  // 读取 template 文件夹
  const fileList = fse.readdirSync(originFile);
  const spinner = ora("正在拷贝模板文件...").start();
  fileList.map((file) => {
    // 拷贝文件
    fse.copySync(`${originFile}/${file}`, `${installDir}/${file}`);
  });
  spinner.stop();
  log.success("模板拷贝成功");
}

function getPluginFilePath(targetPath, template) {
  return path.resolve(
    targetPath,
    "node_modules",
    template.npmName,
    "plugins",
    "index.js"
  );
}

async function ejsRender(targetPath, installDir, template, name) {
  // 忽略特定目录或文件
  const { ignore = [] } = template;

  let data;

  // 获取插件
  const pluginPath = getPluginFilePath(targetPath, template);
  if (pathExistsSync(pluginPath)) {
    // 动态加载
    const pluginFn = (await import(pluginPath)).default;
    // 执行插件
    data = await pluginFn({ makeList, makeInput });
  }

  const ejsData = {
    data: {
      name,
      ...data,
    },
  };

  // glob 匹配文件路径
  glob(
    "**",
    {
      cwd: installDir,
      nodir: true,
      // 忽略特定目录或文件
      ignore: [...ignore, "**/node_modules/**"],
    },
    (err, files) => {
      files.forEach((file) => {
        const filePath = path.join(installDir, file);
        log.verbose("filePath", filePath);
        // ejs 模板渲染
        ejs.renderFile(filePath, ejsData, (err, result) => {
          if (!err) {
            // result:渲染后的 html 字符串
            // 把渲染后的内容写回文件
            fse.writeFileSync(filePath, result);
          } else {
            log.error(err);
          }
        });
      });
    }
  );
}

async function installTemplate(selectedTemplate, opts) {
  // force: 是否强制安装
  const { force = false } = opts;
  const { targetPath, name, template } = selectedTemplate;

  // process.cwd():返回 Node.js 进程的当前工作目录
  const rootDir = process.cwd();
  fse.ensureDirSync(targetPath);
  // 项目目录
  const installDir = path.resolve(`${rootDir}/${name}`);

  if (pathExistsSync(installDir)) {
    if (!force) {
      log.error(`当前目录下已存在 ${installDir} 文件夹`);
      return;
    } else {
      // 删除项目目录
      fse.removeSync(installDir);
      // 重新创建项目目录
      fse.ensureDirSync(installDir);
    }
  } else {
    // 创建项目目录
    fse.ensureDirSync(installDir);
  }

  // 拷贝模板文件
  copyFile(targetPath, template, installDir);

  // 模板动态渲染
  await ejsRender(targetPath, installDir, template, name);
}

export default installTemplate;

工具演示

🌰:command help

🌰:react18

🌰:vue3

🌰:vue-element-admin

源码下载工具

🤔 为什么要开发源码下载工具?

  • npm 只能下载 npm registry 中的包,而对于未上传 npm 的包无法下载
  • 对于托管到 github 或 gitee 的项目或包,如果手动下载效率低下,可以通过开发源码下载工具来提升效率。

🤔 如何实现源码下载工具?

  • 掌握 github API 和 gitee API 的使用方法
  • 存储 github 或 gitee 的 token,具备 github API 或 gitee API 的调用条件
  • 通过 github 和 gitee 实现根据仓库名称搜索项目,选取指定版本(支持翻页)
  • 将指定版本的源码拉取到本地,在本地安装依赖,安装可执行文件,启动项目

调整 package.json

{
  "name": "@oweqian/install",
  "version": "0.0.0",
  "description": "install command",
  // ...
  "main": "lib/index.js",
  // ...
  "type": "module",
  "dependencies": {
    "@oweqian/utils": "^0.0.0",
    "@oweqian/command": "^0.0.0",
    "ora": "^6.1.2"
  }
}

入口文件

import ora from "ora";
import Command from "@oweqian/command";
import {
  log,
  makeList,
  makeInput,
  initGitServer,
  printErrorLog,
  clearCache,
} from "@oweqian/utils";

// 上一页
const PREV_PAGE = "${prev_page}";
// 下一页
const NEXT_PAGE = "${next_page}";
// 搜索仓库
const SEARCH_MODE_REPO = "search_repo";
// 搜索源码
const SEARCH_MODE_CODE = "search_code";

class InstallCommand extends Command {
  get command() {
    return "install";
  }

  get description() {
    return "install project";
  }

  get options() {
    return [["-c, --clear", "清空缓存", false]];
  }

  async action([{ clear }]) {
    if (clear) {
      clearCache();
    }
    await this.generateGitAPI();
    await this.searchGitAPI();
    await this.selectTags();
    log.verbose("full_name", this.keyword);
    log.verbose("selected_tag", this.selectedTag);
    await this.downloadRepo();
    await this.installDependencies();
    await this.runRepo();
  }

  async generateGitAPI() {
    // 初始化 gitAPI、platform、token
    this.gitAPI = await initGitServer();
  }

  async doSearch() {
    const platform = this.gitAPI.getPlatform();

    // 搜索结果
    let searchResult;
    // 总条数
    let count = 0;
    // 组装 choices
    let list = [];

    if (platform === "github") {
      // 2、生成搜索参数
      // github
      const params = {
        q: `${this.q}${this.language ? `+language:${this.language}` : ""}`,
        order: "desc",
        // sort: "stars",
        per_page: this.perPage,
        page: this.page,
      };
      log.verbose("search params", params);

      if (this.mode === SEARCH_MODE_REPO) {
        searchResult = await this.gitAPI.searchRepositories({
          ...params,
        });

        list = searchResult.items.map((item) => ({
          name: `${item.full_name}${item.description}`,
          value: item.full_name,
        }));
      } else {
        searchResult = await this.gitAPI.searchCode({
          ...params,
        });
        list = searchResult.items.map((item) => ({
          name: `${item.repository.full_name}${
            item.repository && item.repository.description
          }`,
          value: item.repository.full_name,
        }));
      }

      count = searchResult.total_count; // 整体数据量
    } else {
      // 2、生成搜索参数
      // gitee
      const params = {
        q: this.q,
        order: "desc",
        // sort: "stars_count",
        per_page: this.perPage,
        page: this.page,
      };
      log.verbose("search params", params);

      if (this.language) {
        params.language = this.language;
      }

      searchResult = await this.gitAPI.searchRepositories({
        ...params,
      });
      count = 999;
      list = searchResult.map((item) => ({
        name: `${item.full_name}${item.description}`,
        value: item.full_name,
      }));
    }

    /**
     * 判断当前页面是否已经到达最大页面
     * 组装 choices 的上一页 / 下一页
     */
    if (
      (platform === "github" && this.page * this.perPage < count) ||
      list.length > 0
    ) {
      list.push({
        name: "下一页",
        value: NEXT_PAGE,
      });
    }

    if (this.page > 1) {
      list.unshift({
        name: "上一页",
        value: PREV_PAGE,
      });
    }

    if (count > 0) {
      const keyword = await makeList({
        message:
          platform === "github"
            ? `请选择要下载的项目(共${count}条数据)`
            : "请选择要下载的项目",
        choices: list,
      });

      if (keyword === NEXT_PAGE) {
        await this.nextPage();
      } else if (keyword === PREV_PAGE) {
        await this.prevPage();
      } else {
        // 下载项目
        this.keyword = keyword;
      }
    }
  }

  async nextPage() {
    this.page++;
    await this.doSearch();
  }

  async prevPage() {
    this.page--;
    await this.doSearch();
  }

  async searchGitAPI() {
    // 1、收集搜索关键词和开发语言
    const platform = this.gitAPI.getPlatform();

    if (platform === "github") {
      // github 支持仓库、源码两种搜索方式
      this.mode = await makeList({
        message: "请选择搜索模式",
        choices: [
          {
            name: "仓库",
            value: SEARCH_MODE_REPO,
          },
          {
            name: "源码",
            value: SEARCH_MODE_CODE,
          },
        ],
      });
    } else {
      // gitee 只支持仓库搜索方式
      this.mode = SEARCH_MODE_REPO;
    }

    // 搜索关键字
    this.q = await makeInput({
      message: "请输入搜索关键词",
      validate(value) {
        if (value.length > 0) {
          return true;
        } else {
          return "请输入搜索关键词";
        }
      },
    });

    // 开发语言
    this.language = await makeInput({
      message: "请输入开发语言",
    });
    log.verbose("search keyword", this.q, this.language, platform);

    this.page = 1;
    this.perPage = 10;
    await this.doSearch();
  }

  async doSelectTags() {
    const platform = this.gitAPI.getPlatform();

    // 根据 keyword 获取 tags
    let tagsListChoices = [];

    if (platform === "github") {
      const params = {
        page: this.tagPage,
        per_page: this.tagPerPage,
      };
      const tagsList = await this.gitAPI.getTags(this.keyword, { ...params });

      tagsListChoices = tagsList.map((item) => ({
        name: item.name,
        value: item.name,
      }));

      if (tagsListChoices.length > 0) {
        tagsListChoices.push({
          name: "下一页",
          value: NEXT_PAGE,
        });
      }

      if (this.tagPage > 1) {
        tagsListChoices.unshift({
          name: "上一页",
          value: PREV_PAGE,
        });
      }
    } else {
      const tagList = await this.gitAPI.getTags(this.keyword);

      tagsListChoices = tagsList.map((item) => ({
        name: item.name,
        value: item.name,
      }));
    }

    const selectedTag = await makeList({
      message: "请选择tag",
      choices: tagsListChoices,
    });

    if (selectedTag === NEXT_PAGE) {
      await this.nextTags();
    } else if (selectedTag === PREV_PAGE) {
      await this.prevTags();
    } else {
      this.selectedTag = selectedTag;
    }
  }

  async nextTags() {
    this.tagPage++;
    await this.doSelectTags();
  }

  async prevTags() {
    this.tagPage--;
    await this.doSelectTags();
  }

  async selectTags() {
    this.tagPage = 1;
    this.tagPerPage = 10;
    await this.doSelectTags();
  }

  async downloadRepo() {
    // 根据 keyword 和 selectedTag 下载仓库
    const spinner = ora(
      `正在下载: ${this.keyword}${this.selectedTag}...`
    ).start();

    try {
      await this.gitAPI.cloneRepo(this.keyword, this.selectedTag);
      log.success(`下载成功: ${this.keyword}(${this.selectedTag})`);
    } catch (e) {
      printErrorLog(e);
    } finally {
      spinner.stop();
    }
  }

  async installDependencies() {
    const spinner = ora(
      `正在安装依赖: ${this.keyword}${this.selectedTag}...`
    ).start();

    try {
      let ret = await this.gitAPI.installDependencies(
        process.cwd(),
        this.keyword
      );
      if (!ret) {
        log.error(`依赖安装失败: ${this.keyword}(${this.selectedTag})`);
      } else {
        log.success(`依赖安装成功: ${this.keyword}(${this.selectedTag})`);
      }
    } catch (e) {
      printErrorLog(e);
    } finally {
      spinner.stop();
    }
  }

  async runRepo() {
    await this.gitAPI.runRepo(process.cwd(), this.keyword);
  }
}

function Install(instance) {
  return new InstallCommand(instance);
}

export default Install;

工具演示

🌰:command help

🌰:Github - react-material-admin

源码下载:

项目运行:

运行结果:

代码检查工具

🤔 为什么要实现代码检查工具?

  • 不同的项目维护不同的 lint 规范,开发代码检查工具对团队代码规范进行统一管控

  • 在提交代码前对项目进行集中管控,对不符合规范的项目进行拦截

  • 实现自动化测试的自动识别和执行,避免手动执行

🤔 如何实现代码检查工具?

  • eslint 配置 + API 调用
  • jest 和 mocha 自动化测试

准备工作

以 vue 类型的项目为例,新建 eslint-test 项目,执行 npm init 初始化项目。

紧接着,我们在项目里新建一些目录和文件,编写一些关于 javascript、vue 组件、jest、mocha 的代码:

项目地址

eslint-test
├── __test__
|    ├── index.js
|    ├── mocha_test.js
├── src
|    ├── views
|    |    ├── components
|    |    |    ├── index.js
|    |    |    ├── index.vue
|    ├── index.js
|    ├── index2.js
|    ├── sum.js
├── .gitignore
├── package-lock.json
├── package.json

调整 package.json

{
  "name": "@oweqian/lint",
  "version": "0.0.0",
  "description": "lint command",
  // ...
  "main": "lib/index.js",
  // ...
  "type": "module",
  "dependencies": {
    "@oweqian/command": "^0.0.0",
    "@oweqian/utils": "^0.0.0",
    "eslint": "^8.28.0",
    "execa": "^6.1.0",
    "fs-extra": "^10.1.0",
    "jest": "^29.3.1",
    "mocha": "^10.1.0",
    "ora": "^6.1.2",
    "path-exists": "^5.0.0"
  }
}

入口文件

import path from "node:path";
import { ESLint } from "eslint";
import { execa } from "execa";
import ora from "ora";
import jest from "jest";
import Mocha from "mocha";
import { pathExistsSync } from "path-exists";
import fse from "fs-extra";
import Command from "@oweqian/command";
import { log, printErrorLog, makeList } from "@oweqian/utils";
import vueConfig from "./eslint/vueConfig.js";

const TEMP_HOME = ".cli-oweqian";

class LintCommand extends Command {
  get command() {
    return "lint";
  }

  get description() {
    return "lint project";
  }

  get options() {}

  extractESLint(resultText, type) {
    const problems = /([0-9]+) problems/;
    const warnings = /([0-9]+) warnings/;
    const errors = /([0-9]+) errors/;
    switch (type) {
      case "problems":
        return resultText.match(problems)[0].match(/[0-9]+/)[0];
      case "errors":
        return resultText.match(errors)[0].match(/[0-9]+/)[0];
      case "warnings":
        return resultText.match(warnings)[0].match(/[0-9]+/)[0];
      default:
        return null;
    }
  }

  parseESLintResult(resultText) {
    // eslint 检查结果分类
    const problems = this.extractESLint(resultText, "problems");
    const errors = this.extractESLint(resultText, "errors");
    const warnings = this.extractESLint(resultText, "warnings");
    return {
      problems: +problems || 0,
      errors: +errors || 0,
      warnings: +warnings || 0,
    };
  }

  async eslint() {
    const cwd = process.cwd();
    // 1、eslint
    // 准备工作,安装依赖
    const spinner = ora("正在安装依赖...").start();

    try {
      // 安装 eslint-plugin-vue@9.8.0
      await execa("npm", ["install", "-D", "eslint-plugin-vue@9.8.0"], {
        cwd,
        stdout: "inherit",
      });
      // 安装 eslint-config-airbnb-base@15.0.0
      await execa(
        "npm",
        ["install", "-D", "eslint-config-airbnb-base@15.0.0"],
        {
          cwd,
          stdout: "inherit",
        }
      );
    } catch (e) {
      printErrorLog(e);
    } finally {
      spinner.stop();
    }

    log.info("正在执行 eslint 检查");

    // 执行工作,eslint
    const eslint = new ESLint({ cwd, overrideConfig: vueConfig });
    // 匹配 src 下的所有 js、vue 文件
    const results = await eslint.lintFiles(["./src/**/*.js", "./src/**/*.vue"]);
    const formatter = await eslint.loadFormatter("stylish");
    const resultText = formatter.format(results);
    const eslintResult = this.parseESLintResult(resultText);

    log.verbose("eslintResult", eslintResult);
    log.success(
      "eslint检查完毕",
      "问题: " + eslintResult.problems,
      ",错误: " + eslintResult.errors,
      ",警告: " + eslintResult.warnings
    );
  }

  async autoTest() {
    const cwd = process.cwd();

    // 执行自动化测试前,让用户选择采用哪种方式进行测试
    let testMode;
    let config;
    // 读取缓存文件,获取 testMode
    const oweqianConfigFile = path.resolve(cwd, TEMP_HOME);
    if (pathExistsSync(oweqianConfigFile)) {
      config = fse.readJSONSync(oweqianConfigFile);
      testMode = config.testMode;
      if (!testMode) {
        testMode = await makeList({
          message: "请选择自动化测试方法",
          choices: [
            { name: "jest", value: "jest" },
            { name: "mocha", value: "mocha" },
          ],
        });
        config.testMode = testMode;
        fse.writeJsonSync(oweqianConfigFile, config);
      }
    } else {
      testMode = await makeList({
        message: "请选择自动化测试方法",
        choices: [
          { name: "jest", value: "jest" },
          { name: "mocha", value: "mocha" },
        ],
      });
      fse.writeJsonSync(oweqianConfigFile, {
        testMode,
      });
    }

    if (testMode === "jest") {
      // 2、jest
      log.info("自动执行jest测试");
      await jest.run("test");
      log.success("jest测试执行完毕");
    } else {
      // 3、mocha
      log.info("自动执行mocha测试");
      const mochaInstance = new Mocha();
      mochaInstance.addFile(path.resolve(cwd, "__tests__/mocha_test.js"));
      mochaInstance.run(() => {
        log.success("mocha测试执行完毕");
      });
    }
  }

  async action() {
    // 代码规范检查
    await this.eslint();
    // 自动化测试
    await this.autoTest();
  }
}

function Lint(instance) {
  return new LintCommand(instance);
}

export default Lint;

eslint - vueConfig

// lib/eslint/vueConfig.js
export default {
  env: {
    browser: true,
    es2021: true,
  },
  extends: ["plugin:vue/vue3-essential", "airbnb-base"],
  overrides: [],
  parserOptions: {
    ecmaVersion: "latest",
    sourceType: "module",
  },
  plugins: ["vue"],
  rules: {},
};

工具演示

🌰:command help

🌰:Jest 测试

🌰:Mocha 测试

代码提交工具

🤔 为什么要实现代码提交工具?

优势:

  • 规范代码提交流程
  • 提升代码提交效率

局限:

  • 无法自动解决代码冲突,需要开发人员手动解决代码冲突

🤔 如何实现代码提交工具?

基于对 GitFlow 掌握的情况下,实现代码自动提交功能需要完成 4 个阶段:

  1. 远程仓库初始化阶段,利用 github、gitee API 创建仓库
  1. git 初始化阶段,线上仓库和本地代码进行同步
  1. git 提交阶段,代码冲突情况如何处理
  1. git 发布阶段,自动发布流程设计和实现

准备工作

开发代码提交工具前,我们先来了解下 GitFlow 原理。

A successful Git branching model

  • GitFlow 主流程
  • GitFlow 多人协作流程

调整 package.json

{
  "name": "@oweqian/commit",
  "version": "0.0.0",
  "description": "commit command",
  // ...
  "main": "lib/index.js",
  // ...
  "type": "module",
  "dependencies": {
    "@oweqian/utils": "^0.0.0",
    "fs-extra": "^10.1.0",
    "path-exists": "^5.0.0",
    "semver": "^7.3.8",
    "simple-git": "^3.15.1"
  }
}

入口文件

import Command from "@oweqian/command";
import path from "node:path";
import fse from "fs-extra";
import SimpleGit from "simple-git";
import semver from "semver";
import { pathExistsSync } from "path-exists";
import {
  log,
  initGitType,
  initGitServer,
  clearCache,
  createRemotoRepo,
  makeInput,
  makeList,
} from "@oweqian/utils";

class CommitCommand extends Command {
  get command() {
    return "commit";
  }

  get description() {
    return "commit project";
  }

  get options() {
    return [
      ["-c, --clear", "清空缓存", false],
      ["-p, --publish", "发布", false],
    ];
  }

  async action([{ clear, publish }]) {
    if (clear) {
      clearCache();
    }
    // 阶段 1、远程仓库初始化
    await this.createRomoteRepo();
    // 阶段 2、git 初始化
    await this.initLocal();
    // 阶段 3、git 提交
    await this.commit();
    // 阶段 4、git 发布
    if (publish) {
      await this.publish();
    }
  }

  async createRomoteRepo() {
    // 1、确定 git 平台、生成 git token
    this.gitAPI = await initGitServer();

    // 2、确定仓库类型
    await initGitType(this.gitAPI);

    // 3、创建远程仓库
    const dir = process.cwd();

    const pkgPath = path.resolve(dir, "package.json");
    const pkg = fse.readJSONSync(pkgPath);
    this.name = pkg.name;
    this.version = pkg.version || "1.0.0";
    await createRemotoRepo(this.gitAPI, this.name);

    // 4、生成.gitignore
    const gitIgnorePath = path.resolve(dir, ".gitignore");
    if (!pathExistsSync(gitIgnorePath)) {
      log.info(".gitignore 不存在,开始创建");
      fse.writeFileSync(
        gitIgnorePath,
        `.DS_Store
      node_modules
      /dist
      
      
      # local env files
      .env.local
      .env.*.local
      
      # Log files
      npm-debug.log*
      yarn-debug.log*
      yarn-error.log*
      pnpm-debug.log*
      
      # Editor directories and files
      .idea
      .vscode
      *.suo
      *.ntvs*
      *.njsproj
      *.sln
      *.sw?`
      );
      log.success(".gitignore 创建成功");
    }
  }

  async checkNotCommitted() {
    const status = await this.git.status();

    if (
      status.not_added.length > 0 ||
      status.created.length > 0 ||
      status.deleted.length > 0 ||
      status.modified.length > 0 ||
      status.renamed.length > 0
    ) {
      log.verbose("status", status);

      // 执行 git add
      await this.git.add(status.not_added);
      await this.git.add(status.created);
      await this.git.add(status.deleted);
      await this.git.add(status.modified);
      await this.git.add(status.renamed.map((item) => item.to)); // 获取renamed内容

      // 执行 git commit -m "xxx"
      let message;
      while (!message) {
        message = await makeInput({
          message: "请输入 commit 信息: ",
        });
      }
      await this.git.commit(message);
      log.success("本地 commit 提交成功");
    }
  }

  async initLocal() {
    // 生成 git remote 地址
    const remoteUrl = this.gitAPI.getRepoUrl(
      `${this.gitAPI.login}/${this.name}`
    );

    // 初始化 git 对象
    this.git = SimpleGit(process.cwd());

    // 判断当前项目是否进行 git 初始化
    const gitDir = path.resolve(process.cwd(), ".git");
    if (!pathExistsSync(gitDir)) {
      // 执行 git init
      await this.git.init();
      log.success("完成 git 初始化");
    }

    // 获取所有 remotes
    const remotes = await this.git.getRemotes();
    if (!remotes.find((remote) => remote.name === "origin")) {
      // 执行 git add remote
      this.git.addRemote("origin", remoteUrl);
      log.success("添加 git remote", remoteUrl);
    }

    // 自动提交未提交代码
    await this.checkNotCommitted();

    // 拉取远程 main 分支
    const tags = await this.git.listRemote(["--refs"]);
    log.verbose("listRemotes", tags);

    if (tags.indexOf("refs/heads/main") >= 0) {
      // 拉取远程 main 分支,实现代码同步
      await this.pullRemoteRepo("main", {
        "--allow-unrelated-histories": null,
      });
    } else {
      // 推送代码到远程 main 分支
      await this.pushRemoteRepo("main");
    }
  }

  syncVersionToPackageJson() {
    const dir = process.cwd();
    const pkgPath = path.resolve(dir, "package.json");
    const pkg = fse.readJSONSync(pkgPath);
    if (pkg && pkg.version !== this.version) {
      pkg.version = this.version;
      fse.writeJSONSync(pkgPath, pkg, { spaces: 2 });
    }
  }

  /**
   * 版本号数组(排序方式:ASC)
   */
  async getRomoteBranchList(type) {
    const remoteList = await this.git.listRemote(["--refs"]);
    let reg;
    // 生产 or 开发
    if (type === "release") {
      // release/0.0.1
      reg = /.+?refs\/tags\/release\/(\d+\.\d+\.\d+)/g;
    } else {
      // develop/0.0.1
      reg = /.+?refs\/tags\/develop\/(\d+\.\d+\.\d+)/g;
    }
    return remoteList
      .split("\n")
      .map((remote) => {
        const match = reg.exec(remote);
        reg.lastIndex = 0;
        // semver 判断是否为有效版本号
        if (match && semver.valid(match[1])) {
          return match[1];
        }
      })
      .filter(Boolean)
      .sort((a, b) => {
        // ASC
        if (semver.lte(b, a)) {
          if (a === b) return 0;
          return -1;
        }
        return 1;
      });
  }

  async getCorrectVersion() {
    log.info("获取代码分支");

    const remoteBranchList = await this.getRomoteBranchList("release");
    let releaseVersion = null;
    if (remoteBranchList && remoteBranchList.length > 0) {
      releaseVersion = remoteBranchList[0];
    }

    const developVersion = this.version;

    if (!releaseVersion) {
      this.branch = `develop/${developVersion}`;
    } else if (semver.gt(developVersion, releaseVersion)) {
      log.info(
        `当前本地版本号大于线上最新版本号`,
        `${developVersion} > ${releaseVersion}`
      );

      this.branch = `develop/${developVersion}`;
    } else {
      log.info(
        `当前线上最新版本号大于本地版本号`,
        `${releaseVersion} > ${developVersion}`
      );

      const incType = await makeList({
        message: "自动升级版本,请选择升级版本类型",
        defaultValue: "patch",
        choices: [
          {
            name: `小版本 (${releaseVersion} -> ${semver.inc(
              releaseVersion,
              "patch"
            )})`,
            value: "patch",
          },
          {
            name: `中版本 (${releaseVersion} -> ${semver.inc(
              releaseVersion,
              "minor"
            )})`,
            value: "minor",
          },
          {
            name: `大版本 (${releaseVersion} -> ${semver.inc(
              releaseVersion,
              "major"
            )})`,
            value: "major",
          },
        ],
      });

      const incVersion = semver.inc(releaseVersion, incType);
      this.branch = `develop/${incVersion}`;
      this.version = incVersion;
      // 更新 package.json version
      this.syncVersionToPackageJson();
    }

    log.success(`代码分支获取成功 ${this.branch}`);
  }

  async checkStash() {
    log.info("检查 stash 记录");

    const stashList = await this.git.stashList();

    if (stashList.all.length > 0) {
      await this.git.stash(["pop"]);
      log.success("stash pop 成功");
    }
  }

  async checkConflicted() {
    log.info("代码冲突检查");

    const status = await this.git.status();

    if (status.conflicted.length > 0) {
      throw new Error("当前代码存在冲突,请手动处理合并后再试!");
    }

    log.success("代码冲突检查通过");
  }

  async checkoutBranch(branchName) {
    const localBranchList = await this.git.branchLocal();

    if (localBranchList.all.indexOf(branchName) >= 0) {
      await this.git.checkout(branchName);
    } else {
      await this.git.checkoutLocalBranch(branchName);
    }

    log.success(`本地分支切换到 ${branchName}`);
  }

  async pullRemoteRepo(branch = "main", options = {}) {
    // 合并远程分支代码
    log.info(`同步远程 ${branch} 分支代码`);

    await this.git.pull("origin", branch, options).catch((err) => {
      log.verbose(`git pull origin ${branch}`, err.message);
      if (err.message.indexOf(`couldn't find remote ref ${branch}`) >= 0) {
        log.warn(`获取远程 [${branch}] 分支失败`);
      }
      process.exit(0);
    });
  }

  async pullRemoteMainAndBranch() {
    // 合并远程 main 分支
    log.info(`合并 [main] -> [${this.branch}]`);
    await this.pullRemoteRepo();
    log.success("合并远程 [main] 分支成功");
    log.info("检查远程分支");

    // 合并远程开发分支
    const remoteBranchList = await this.getRomoteBranchList();
    if (remoteBranchList.indexOf(this.version) >= 0) {
      log.info(`合并 [${this.branch}] -> [${this.branch}]`);
      await this.pullRemoteRepo(this.branch);
      log.success(`合并远程 [${this.branch}] 分支成功`);
      await this.checkConflicted();
    } else {
      log.success(`不存在远程分支 [${this.branch}]`);
    }
  }

  async pushRemoteRepo(branchName) {
    log.info(`推送代码至远程 ${branchName} 分支`);
    await this.git.push("origin", branchName);
    log.success("推送代码成功");
  }

  async commit() {
    // 自动生成版本号
    await this.getCorrectVersion();
    // 检查 stash 区
    await this.checkStash();
    // 检查代码冲突
    await this.checkConflicted();
    // 自动提交未提交代码
    await this.checkNotCommitted();
    // 自动切换分支
    await this.checkoutBranch(this.branch);
    // 自动合并远程 main 分支和远程开发分支
    await this.pullRemoteMainAndBranch();
    // 推送开发分支至远程开发分支
    await this.pushRemoteRepo(this.branch);
  }

  async checkTag() {
    log.info("获取远程 tag 列表");

    const tag = `release/${this.version}`;
    const tagList = await this.getRomoteBranchList("release");

    if (tagList.includes(this.version)) {
      log.info("远程 tag 已存在", tag);
      await this.git.push(["origin", `:refs/tags/${tag}`]);
      log.success("远程 tag 已删除", tag);
    }

    const localTagList = await this.git.tags();

    if (localTagList.all.includes(tag)) {
      log.info("本地 tag 已存在", tag);
      await this.git.tag(["-d", tag]);
      log.success("本地 tag 已删除", tag);
    }

    await this.git.addTag(tag);
    log.success("本地 tag 创建成功", tag);

    await this.git.pushTags("origin");
    log.success("远程 tag 推送成功", tag);
  }

  async mergeBranchToMain() {
    log.info("开始合并代码", `[${this.branch}] -> [main]`);
    await this.git.mergeFromTo(this.branch, "main");
    log.success("代码合并成功", `[${this.branch}] -> [main]`);
  }

  async deleteLocalBranch() {
    log.info("开始删除本地分支", this.branch);
    await this.git.deleteLocalBranch(this.branch);
    log.success("删除本地分支成功", this.branch);
  }

  async deleteRemoteBranch() {
    log.info("开始删除远程分支", this.branch);
    await this.git.push(["origin", "--delete", this.branch]);
    log.success("删除远程分支成功", this.branch);
  }

  async publish() {
    // 创建 Tag 并推送至远程 Tag
    await this.checkTag();
    // 本地切换到 main 分支
    await this.checkoutBranch("main");
    // 将开发分支代码合并到 main 分支
    await this.mergeBranchToMain();
    // 推送远程 main 分支
    await this.pushRemoteRepo("main");
    // 删除本地开发分支
    await this.deleteLocalBranch();
    // 删除远程开发分支
    await this.deleteRemoteBranch();
  }
}

function Commit(instance) {
  return new CommitCommand(instance);
}

export default Commit;

工具演示

🌰:command help

🌰:Github User

项目地址

🌰:Github Organization

项目地址

🌰:Gitee User

项目地址

版本发布:

版本升级:

🌰:Gitee Organization

项目地址

脚手架云发布

🤔 为什么要实现自动发布?

优势:

  • 减少发布过程中的手动操作成本,大幅提升 CI/CD 效率,快速实现项目发布上线

局限:

  • 存在较高的技术门槛
  • 需要租用额外的服务,会产生技术成本

🤔 如何实现自动发布?

  • 方案 1:完全自主实现自动发布逻辑
  • 方案 2:使用 github actions
  • 方案 3:使用 docker + jenkins

Github Actions

Github Actions 是 Github 官方推出的 CI/CD 解决方案

工作原理:

工具演示

📢 因为本人没有充足 💰 购买服务器和域名,所以只跑了下大概流程,如果您有需要,请自行查找其他资料。

🌰:Github Actions

🌰:docker + jenkins

写在最后

Everything will be okay in the end. If it’s not okay, it’s not the end.