从0到1开发一个基于小程序CI的命令行工具

1,593 阅读9分钟

在之前的文章中,笔者介绍了如何使用一行命令就自动上传小程序代码以及如何使用命令自动发送体验二维码到飞书群聊,这两种提高效率的方法都是通过 npm run xxx的方法来运行项目本地的node.js脚本文件的。想象一下,如果我还有其他小程序项目或者其他同事负责的项目要使用我这种方案来搞一下,那么岂不是需要再拷贝粘贴一遍脚本文件,然后手动安装一堆依赖,新建相关的配置文件等。这样是不是显得挺low的?于是笔者灵机一动:想把这两个方案封装整合一下设计成命令行工具,然后发布成npm包,让使用者全局安装后就可以很方便地使用,这样起不美哉

理想很丰满,现实很骨感!无奈咱也不会开发命令行工具呀!正在百感交集,一筹莫展之时想起了之前看过若川大佬的文章《还在用开发者工具上传小程序? 快来试试 miniprogram-ci 提效摸鱼》,于是又把此文认真地看了一遍,然后又把大佬的代码clone下来认真地研究了一下。一上午过去了,笔者陷入了沉思中,从内心由衷地感慨到:大佬是真牛啊,自己是真菜啊。经历了一阵思想斗争之后,决定要照葫芦画瓢也写一个命令行工具。接着就结合自己的需求,参考大佬的文章和代码,构思了自己要开发的命令行工具

命令设计

笔者为自己亲生的命令行工具设计了如下几个命令:

  1. init: 对项目进行初始化。此过程会完成如下事情:
  • 在当前小程序项目的根目录下创建qrcode目录用于存放小程序的预览二维码。
  • 在当前小程序项目的根目录下创建.release-it.json文件用于配置release-it 。
  • 在当前小程序项目的根目录下创建CHANGELOG.md用于记录小程序代码的变本变更。
  • 为当前小程序项目安装必要的依赖(git-cz,release-it,@release-it/conventional-changelog)。
  • 修改当前小程序项目的package.json文件,并增加用于运行git-cz和release-it的npm scripts。
  • 在当前小程序项目的根目录下创建ci.config.js文件用于配置调用miniprogram-ci相关功能的配置以及调用飞书机器人的相关配置,详见配置文件章节。
  1. uploadCode:将小程序项目代码上传到gitlab(或者github等)。此命令会进行如下操作:
  • 执行 git add . 命令
  • 执行 npm run commit 命令,调用git-cz
  • 执行 npm run release 命令,调用release-it
  1. preview: 获取小程序的预览二维码,并保存到init命令创建的qrcode目录下。
  2. upload: 将小程序代码上传到开发者后台。如果working tree不是clean状态则会调用uploadCode命令强制将代码先提交到Gitlab,然后再上传代码,为上传的代码提供可追溯的版本。
  3. send:获取预览二维码,将代码上传到小程序开发者后台,将二维码发送到飞书群聊。此命令会调用preview命令和upload命令。

一图胜千言,来张图总结一下:

命令设计好了,那么该用啥来开发命令呢?重要角色cac 登场啦!它可是个狠人儿,自称是用于构建命令行应用的简单而强大的框架。下面就看看笔者是如何使用的吧:

import { cac } from "cac";
import { upload, preview, init, send, uploadCode } from "./commands";

const cli = cac("mx-mini-ci-tool");
cli.option("-d, --dry", "空跑");
cli
  .command("init [root]", "初始化配置项")
  .action(async (root: string, options) => {
    init({ root, ...options });
  });

cli
  .command("upload [root]", "上传小程序代码到开发者后台")
  .action(async (root: string, options) => {
    upload({ root, ...options });
  });

cli
  .command("preview [root]", "预览小程序")
  .action(async (root: string, options) => {
    preview({ root, ...options });
  });

cli
  .command("uploadCode [root]", "上传小程序代码到gitlab")
  .action(async (root: string, options) => {
    uploadCode();
  });

cli
  .command("send [root]", "上传小程序并将预览二维码发送到飞书")
  .action(async (root: string, options) => {
    send({ root, ...options });
  });

cli.parse();

如上代码首先引入cac并使用cac创建了cli实例,名字为mx-mini-ci-tool;command方法用于创建命令实例;命令实例的action方法用一个回调函数指定命令的执行动作;cli实例的parse方法被调用才能让定义的命令可以执行。

下面说说这些命令实现过程中一些重要的点,对于如何使用小程序CI上传和预览代码以及如何把预览二维码发送到飞书可以参考前两篇文章,这里就不赘述啦。

创建相关文件和创建qrcode目录

前面说了,init命令要创建好多文件还有qrcode目录。这个需求并不难,我们可以将准备好的文件拷贝到当前执行命令的目录(也就是小程序项目的根目录),然后创建一个qrcode目录。如下图所示template目录下存放的是要拷贝的文件:

相关代码实现如下:

const cwd = process.cwd();
// 项目根目录
export const root = path.join(cwd);
const copy = (file: string) => {
	const targetPath = path.join(root, file);
  fs.copyFileSync(file, targetPath);
}
// 模板文件目录
const templateDir = path.resolve(
  fileURLToPath(import.meta.url),
  "../../src/",
  `template`
);
// 拷贝模板文件和创建qrcode目录
export const copyTemplateFile = () => {
  const files = fs.readdirSync(templateDir);
  for (const file of files) {
    copy(file);
  }
	fs.mkdirSync(path.join(root, '/qrcode'))
};

必要依赖的预安装和修改package.json文件

我们的命令行工具要为当前的小程序项目安装依赖,安装依赖无非就是运行 npm install 巴拉巴拉,如何在代码中运行命令呢?又一个重要角色execa 登场啦!它也是个狠人儿,是对node.js child_process的改进。我们看一下如何使用:

import { execa } from "execa";
export const run = (bin: string, args: string[], opts = {}) =>
  execa(bin, args, {
    stdio: "inherit",
    ...opts
  });
// 安装依赖
await run("npm", ["install", "git-cz","release-it","@release-it/conventional-changelog", "-D"]);

再来看一下修改package.json文件:

// 修改package.json
const  packageData = JSON.parse(fs.readFileSync(path.join(root,'package.json'), "utf8"));
packageData.scripts.commit = 'git-cz',
packageData.scripts.release = 'release-it',
fs.writeFileSync(path.join(root,'package.json'), JSON.stringify(packageData,null, 2))

通过如上代码就可以在package.json中写入两条npm script, 如下图所示:

配置文件(ci.config.js)的设计

通过笔者前几篇的文章我们知道,无论是使用小程序CI工具还是使用飞书机器人都是需要一些配置和参数的,显然这些配置和参数应该留给开发者自己来填写,不能写死在工具中,于是就需要一个配置文件,如下代码所示:

export default {
  // new ci.Project的参数
  newProjectSettings: {
    appid: 'wx42066******be8d19',
    type: 'miniProgram',
    projectPath: 'E:/GitSpace/fs-new/lio-4s-applet',
    privateKeyPath: 'E:/GitSpace/fs-new/lio-4s-applet/private.wx420663f291be8d19.key',
    ignores: ['node_modules/**/*'],
  },
  // ci.upload的参数
  ciUploadSettings: {
    setting: {
      es6: true,
      minifyJS: true,
      minifyWXML: true,
      minifyWXSS: true
    },
    onProgressUpdate: console.log,
  },
  // ci.preview的参数
  ciPreviewSettings: {
    setting: {
      es6: true,
      minifyJS: true,
      minifyWXML: true,
      minifyWXSS: true
    },
    qrcodeFormat: 'image',
    qrcodeOutputDest: 'E:/GitSpace/fs-new/lio-4s-applet/qrcode/destination.jpg',
    onProgressUpdate: console.log,
  },
  // 发送到飞书的参数
  feishuSendSettings: {
    //app_id和app_secret飞书机器人应用的凭证信息
    app_id: 'cli_a2*****f8100e',
    app_secret: 'UayK*******YTJVnEym3P',
    // 后台提供的对飞书根据图片获取image_key接口的封装接口,详见飞书文档
    uploadImgUrl: 'https://************tool/image/upload',
    // 飞书机器人 webhook 地址
    webhookUrl: 'https://open.*******745426f55',
    // 飞书消息要通知到的人
    receivers:[
      {
        tag: 'at',
        user_id: '70652****972',
        user_name: '惊鸿'
      },
      // {
      //   tag: 'at',
      //   user_id: '705224***96225',
      //   user_name: 'NewName'
      // }
    ]
  }
}

有了配置文件我们的命令对应的脚本就可以读取配置文件中的数据了,相关代码如下:

// 获取CI配置文件对象
export const getCiConfig = async () => {
  let fileNameTmp: string = "";
  try {
    const filePath = path.join(root, "ci.config.js");
    const fileBase = `${filePath}.timestamp-${Date.now()}`;
    fileNameTmp = `${fileBase}.mjs`;
    let fileUrl = `${pathToFileURL(fileBase)}.mjs`;
    const code = fs.readFileSync(filePath, "utf8");
    fs.writeFileSync(fileNameTmp, code, "utf8");
    const res = await import(fileUrl);
    const ciConfig = res.default;
    return ciConfig;
  } catch (err) {
    console.log(`加载配置文件失败`, err);
    return;
  } finally {
    try {
      fs.unlinkSync(fileNameTmp);
    } catch {
      // already removed if this function is called twice simultaneously
    }
  }
};
const ciConfig = await getCiConfig();
const { newProjectSettings,ciPreviewSettings } = ciConfig;
const project = new ci.Project({
  ...newProjectSettings
})

打包和发布为npm包

经过本地对命令的测试后,接下来就是要打包和发布为npm包了。第三个重要角色 unbuild 登场啦!它自称是一个统一的javascript构建系统。来看看如何使用:

首先安装为开发依赖:npm install unbuild -D

然后增加一个npm script :

"scripts": {
  "build": "unbuild"
},

根目录下增加一个build.config.ts配置文件,对打包入口等进行配置:

import { defineBuildConfig } from 'unbuild';

export default defineBuildConfig({
	entries: ['src/index'],
	clean: true,
	declaration: true,
	externals: ['vitest'],
	rollup: {
		emitCJS: true,
		inlineDependencies: true,
	},
});

这样就完成了打包配置,然后就可以愉快地进行 npm run build 来打包了。打包之后就是要发布为npm包啦,需要对package.json增加点儿简单的配置,然后就可以发布npm包。关于如何发布npm包,可以阅读笔者的文章《图文结合简单易学的npm 包的发布流程》。

使用效果

当npm包发布成功之后就可以全局安装并使用啦:npm install mx-mini-ci-tool -g,如果全局安装成功了但是小程序开发者工具的终端不识别我们的工具则可以参考网搜的解决办法搞一下就ok啦,如下图所示:

然后我们来使用一下命令:

send命令执行过程中需要和命令行来几次简单的交互,如果提前运行了uoloadCode命令提交代码了,那么咱就一路回车就ok,send最终的效果如下图所示:

然后我们看看飞书群聊,机器人确实已经将版本发布通知发布成功啦:

总结与注意事项

  1. 由于本工具使用了 miniprogram-ci ,所以使用本工具之前应访问"微信公众平台-开发 - 开发设置"后下载代码上传密钥,并配置 IP 白名单 开发者可选择打开 IP 白名单,打开后只有白名单中的 IP 才能调用相关接口。
  2. 注意调用完init命令后要及时修改ci.config.js配置文件。
  3. 关于使用飞书机器人的注意事项:首先是你的飞书机器人要使用发送图片接口则要开通权限,这个可以参照飞书的开发者文档。其次是ci.config.js配置文件中的uploadImgUrl字段是后端开发人员对飞书接口的进一步封装的接口,接口返回格式需要为如下格式:
{
	"code": 0,
	"data": {
		"image_key": ""
	},
	"msg": ""
}

所以要使用send命令则需要后端为你开发一个接口。

最后说明一下,笔者的水平实在是吭吃瘪肚(东北话,此处形容菜),为了防止毁人不倦就不把源码开源啦,感兴趣的读者可以自己开发一个啊,也欢迎试试笔者已经发布好的mx-mini-ci-tool 的npm包