Skip to content
On this page

taro-plugin-mini-ci

taro-plugin-mini-ciTaro 小程序端构建后支持 CI 的插件,支持构建完毕后自动打开小程序开发工具、自动上传作为体验码、生成预览二维码。

Usage

配置文件

js
// config/index.js

const CIPluginOpt = {
    weapp: {
        appid: "微信小程序appid",
        privateKeyPath: "密钥文件相对项目根目录的相对路径,例如 key/private.appid.key"
    },
    tt: {
        email: "字节小程序邮箱",
        password: "字节小程序密码"
    },
    alipay: {
      appId: "支付宝小程序appId",
      toolId: "工具id",
      privateKeyPath: "密钥文件相对项目根目录的相对路径,例如 key/pkcs8-private-pem"
    },
    swan: {
      token: "鉴权需要的token令牌"
    },
    // 版本号
    version: "1.0.0",
    // 版本发布描述
    desc: "版本描述"
}
const config = {
  plugins: [
    [ "@tarojs/plugin-mini-ci", CIPluginOpt ]
  ]
}

配置命令

json
{
  "scripts": {
    //  构建完后自动 “打开开发者工具”
    "build:weapp": "taro build --type weapp --open",
    //  构建完后自动“上传代码作为体验版”
    "build:weapp:upload": "taro build --type weapp --upload",
    //  构建完后自动 “上传代码作为开发版并生成预览二维码”     
    "build:weapp:preview": "taro build --type weapp --preview"
  }
}

实现原理

open

shell
C:\\Program Files (x86)\\Tencent\\微信web开发者工具\\cli.bat open --project [appPath]

preview

调用小程序的 CI 预览功能

js
ci.preview({
  project: this.instance,
  version: this.version,
  desc: this.desc,
  onProgressUpdate: undefined
})

upload

调用小程序的 CI 上传功能

js
ci.upload({
  project: this.instance,
  version: this.version,
  desc: this.desc,
  onProgressUpdate: undefined
})

小程序 CI 文档

源码解析

ts
import { IPluginContext } from '@tarojs/service'
import * as minimist from 'minimist'

import AlipayCI from './AlipayCI'
import { CIOptions } from './BaseCi'
import SwanCI from './SwanCI'
import TTCI from './TTCI'
import WeappCI from './WeappCI'

export { CIOptions } from './BaseCi'
export default (ctx: IPluginContext, pluginOpts: CIOptions) => {
  // taro 构建完成钩子
  const onBuildDone = ctx.onBuildComplete || ctx.onBuildFinish
  
  // 为插件添加校验
  ctx.addPluginOptsSchema((joi) => {
    return joi
      .object()
      .keys({
        /** 微信小程序上传配置 */
        weapp: joi.object({
          appid: joi.string().required(),
          projectPath: joi.string(),
          privateKeyPath: joi.string().required(),
          type: joi.string().valid('miniProgram', 'miniProgramPlugin', 'miniGame', 'miniGamePlugin'),
          ignores: joi.array().items(joi.string().required())
        }),
        /** 字节跳动小程序上传配置 */
        tt: joi.object({
          email: joi.string().required(),
          password: joi.string().required()
        }),
        /** 阿里小程序上传配置 */
        alipay: joi.object({
          appId: joi.string().required(),
          toolId: joi.string().required(),
          privateKeyPath: joi.string().required(),
          proxy: joi.string(),
          project: joi.string(),
          clientType: joi.string().valid('alipay', 'ampe', 'amap', 'genie', 'alios', 'uc', 'quark', 'taobao', 'koubei', 'alipayiot', 'cainiao', 'alihealth')
        }),
        /** 百度小程序上传配置 */
        swan: joi.object({
          token: joi.string().required(),
          minSwanVersion: joi.string()
        }),
        version: joi.string(),
        desc: joi.string()
      })
      .required()
  })

  onBuildDone(async () => {
    // 获取命令行参数 {open: true, upload: true, preview: true}
    const args = minimist(process.argv.slice(2), {
      boolean: ['open', 'upload', 'preview']
    })

    // printLog:日志输出 processTypeEnum:输出类型的枚举
    const { printLog, processTypeEnum } = ctx.helper
    // taro 打包目标的运行平台
    const platform = ctx.runOpts.options.platform
    let ci
    switch (platform) {
      case 'weapp':
        ci = new WeappCI(ctx, pluginOpts)
        break
      case 'tt':
        ci = new TTCI(ctx, pluginOpts)
        break
      case 'alipay':
      case 'iot':
        ci = new AlipayCI(ctx, pluginOpts)
        break
      case 'swan':
        ci = new SwanCI(ctx, pluginOpts)
        break
      default:
        break
    }
    if (!ci) {
      printLog(processTypeEnum.WARNING, `"@tarojs/plugin-mini-ci" 插件暂时不支持 "${platform}" 平台`)
      return
    }
    switch (true) {
      case args.open:
        // 打开开发者工具
        ci.open()
        break
      case args.upload:
        // 上传生成体验码
        ci.upload()
        break
      case args.preview:
        // 生成预览码
        ci.preview()
        break
      default:
        break
    }
  })
}

WeappCI

ts
/* eslint-disable no-console */
import * as cp from 'child_process'
import * as ci from 'miniprogram-ci'
import { Project } from 'miniprogram-ci'
import * as os from 'os'
import * as path from 'path'

import BaseCI from './BaseCi'

export default class WeappCI extends BaseCI {
  private instance: Project
  /** 微信开发者安装路径 */
  private devToolsInstallPath: string

  _init () {
    /**
     * outputPath:当前项目输出代码路径
     * appPath:当前命令执行的目录
    */
    const { outputPath, appPath } = this.ctx.paths
    // 文件系统
    const { fs } = this.ctx.helper
    if (this.pluginOpts.weapp == null) {
      throw new Error('请为"@tarojs/plugin-mini-ci"插件配置 "weapp" 选项')
    }

    // 设置微信开发者工具的安装路径
    this.devToolsInstallPath = this.pluginOpts.weapp.devToolsInstallPath || (process.platform === 'darwin' ? '/Applications/wechatwebdevtools.app' : 'C:\\Program Files (x86)\\Tencent\\微信web开发者工具')
    delete this.pluginOpts.weapp.devToolsInstallPath

    const weappConfig: any = {
      type: 'miniProgram',
      projectPath: outputPath,
      ignores: ['node_modules/**/*'],
      ...this.pluginOpts.weapp!
    }

    // 私钥,在获取项目属性和上传时用于鉴权使用(必填)
    const privateKeyPath = path.isAbsolute(weappConfig.privateKeyPath) ? weappConfig.privateKeyPath : path.join(appPath, weappConfig.privateKeyPath)
    if (!fs.pathExistsSync(privateKeyPath)) {
      throw new Error(`"weapp.privateKeyPath"选项配置的路径不存在,本次上传终止:${privateKeyPath}`)
    }

    // 获取微信小程序 CI 的实例
    this.instance = new ci.Project(weappConfig)
  }

  // 打开微信开发者工具
  async open () {
    /**
     * fs:文件系统
     * printLog:日志输出
     * processTypeEnum:日志输出类型
     * getUserHomeDir:获取用户根路径
    */
    const { fs, printLog, processTypeEnum, getUserHomeDir } = this.ctx.helper
    // 当前命令执行的目录
    const { appPath } = this.ctx.paths
    // 检查安装路径是否存在
    if (!(await fs.pathExists(this.devToolsInstallPath))) {
      printLog(processTypeEnum.ERROR, '微信开发者工具安装路径不存在', this.devToolsInstallPath)
      return
    }
    /** 命令行工具所在路径 */
    const cliPath = path.join(this.devToolsInstallPath, os.platform() === 'win32' ? '/cli.bat' : '/Contents/MacOS/cli')
    const isWindows = os.platform() === 'win32'

    // 检查是否开启了命令行
    const errMesg = '工具的服务端口已关闭。要使用命令行调用工具,请打开工具 -> 设置 -> 安全设置,将服务端口开启。详细信息: https://developers.weixin.qq.com/miniprogram/dev/devtools/cli.html'
    // 开发者工具下载路径
    const installPath = isWindows ? this.devToolsInstallPath : `${this.devToolsInstallPath}/Contents/MacOS`
    // 将开发者工具下载路径转成 md5
    const md5 = require('crypto').createHash('md5').update(installPath).digest('hex')
    // 获取开发者工具 ide 的状态文件路径
    const ideStatusFile = path.join(
      getUserHomeDir(),
      isWindows
        ? `/AppData/Local/微信开发者工具/User Data/${md5}/Default/.ide-status`
        : `/Library/Application Support/微信开发者工具/${md5}/Default/.ide-status`
    )
    if (!(await fs.pathExists(ideStatusFile))) {
      printLog(processTypeEnum.ERROR, errMesg)
      return
    }
    // 获取当前 ide 的状态
    const ideStatus = await fs.readFile(ideStatusFile, 'utf-8')
    if (ideStatus === 'Off') {
      printLog(processTypeEnum.ERROR, errMesg)
      return
    }

    if (!(await fs.pathExists(cliPath))) {
      printLog(processTypeEnum.ERROR, '命令行工具路径不存在', cliPath)
    }
    printLog(processTypeEnum.START, '微信开发者工具...')
    // 执行 C:\\Program Files (x86)\\Tencent\\微信web开发者工具\\cli.bat open --project
    cp.exec(`${cliPath} open --project ${appPath}`, (err) => {
      if (err) {
        printLog(processTypeEnum.ERROR, err.message)
      }
    })
  }

  // 生成预览码
  async preview () {
    /**
     * chalk:打印有色彩的输出
     * printLog:日志输出
     * processTypeEnum:日志输出类型
    */
    const { chalk, printLog, processTypeEnum } = this.ctx.helper
    try {
      printLog(processTypeEnum.START, '上传开发版代码到微信后台并预览')
      // 生成预览码,返回上传信息
      const uploadResult = await ci.preview({
        project: this.instance,
        version: this.version,
        desc: this.desc,
        onProgressUpdate: undefined
      })

      if (uploadResult.subPackageInfo) {
        // 小程序包信息, name 为 __FULL__ 时表示整个小程序包, name 为 __APP__ 时表示小程序主包,其他情况都表示分包
        const allPackageInfo = uploadResult.subPackageInfo.find((item) => item.name === '__FULL__')
        const mainPackageInfo = uploadResult.subPackageInfo.find((item) => item.name === '__APP__')
        const extInfo = `本次上传${allPackageInfo!.size / 1024}kb ${mainPackageInfo ? ',其中主包' + mainPackageInfo.size + 'kb' : ''}`
        console.log(chalk.green(`上传成功 ${new Date().toLocaleString()} ${extInfo}`))
      }
    } catch (error) {
      console.log(chalk.red(`上传失败 ${new Date().toLocaleString()} \n${error.message}`))
    }
  }

  // 上传
  async upload () {
     /**
     * chalk:打印有色彩的输出
     * printLog:日志输出
     * processTypeEnum:日志输出类型
    */
    const { chalk, printLog, processTypeEnum } = this.ctx.helper
    try {
      printLog(processTypeEnum.START, '上传体验版代码到微信后台')
      printLog(processTypeEnum.REMIND, `本次上传版本号为:"${this.version}",上传描述为:“${this.desc}`)
      // 上传
      const uploadResult = await ci.upload({
        project: this.instance,
        version: this.version,
        desc: this.desc,
        onProgressUpdate: undefined
      })

      if (uploadResult.subPackageInfo) {
        // 小程序包信息
        const allPackageInfo = uploadResult.subPackageInfo.find((item) => item.name === '__FULL__')
        const mainPackageInfo = uploadResult.subPackageInfo.find((item) => item.name === '__APP__')
        const extInfo = `本次上传${allPackageInfo!.size / 1024}kb ${mainPackageInfo ? ',其中主包' + mainPackageInfo.size + 'kb' : ''}`
        console.log(chalk.green(`上传成功 ${new Date().toLocaleString()} ${extInfo}`))
      }
    } catch (error) {
      console.log(chalk.red(`上传失败 ${new Date().toLocaleString()} \n${error.message}`))
    }
  }
}